Skip to content

Commit

Permalink
Merge branch 'master' into release
Browse files Browse the repository at this point in the history
  • Loading branch information
suricactus committed Sep 12, 2022
2 parents 6dcd380 + c1d1100 commit ec593d7
Show file tree
Hide file tree
Showing 24 changed files with 361 additions and 95 deletions.
8 changes: 2 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,9 @@ EMAIL_HOST_PASSWORD=password
DEFAULT_FROM_EMAIL=webmaster@localhost

QFIELDCLOUD_DEFAULT_NETWORK=qfieldcloud_default
QFIELDCLOUD_ADMIN_URI=admin/

# Ribbon content on the top-right. Unescaped HTML, leave empty to disable the ribbon.
QFIELDCLOUD_RIBBON_HTML=<a class="qfc-ribbon" href="https://qfield.cloud/tos" target="_blank">DEV</a>

# Timeout in seconds to wait for a job container to finish, otherwise terminate it.
QFIELDCLOUD_WORKER_TIMEOUT_S=60
# Admin URI. Requires slash in the end. Please use something that is hard to guess.
QFIELDCLOUD_ADMIN_URI=admin/

# QFieldCloud URL used within the worker as configuration for qfieldcloud-sdk
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://app:8000/api/v1/
Expand Down
26 changes: 26 additions & 0 deletions docker-app/qfieldcloud/authentication/auth_backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from allauth.account.auth_backends import (
AuthenticationBackend as AllAuthAuthenticationBackend,
)


class AuthenticationBackend(AllAuthAuthenticationBackend):
"""Extend the original `allauth` authentication backend to limit user types who can sign in.
Sign in via team or organization should be forbidden.
"""

def _authenticate_by_username(self, **credentials):
user = super()._authenticate_by_username(**credentials)

if user and user.is_user:
return user

return None

def _authenticate_by_email(self, **credentials):
user = super()._authenticate_by_email(**credentials)

if user and user.is_user:
return user

return None
47 changes: 44 additions & 3 deletions docker-app/qfieldcloud/authentication/tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.utils.timezone import datetime, now
from qfieldcloud.authentication.models import AuthToken
from qfieldcloud.core.models import User
from qfieldcloud.core.models import Organization, Team, User
from qfieldcloud.core.tests.utils import setup_subscription_plans
from rest_framework.test import APITransactionTestCase

Expand Down Expand Up @@ -42,7 +42,7 @@ def assertTokenMatch(self, token, payload):
)
)

def login(self, username, password, user_agent=""):
def login(self, username, password, user_agent="", success=True):
response = self.client.post(
"/api/v1/auth/login/",
{
Expand All @@ -52,7 +52,8 @@ def login(self, username, password, user_agent=""):
HTTP_USER_AGENT=user_agent,
)

self.assertEqual(response.status_code, 200)
if success:
self.assertEqual(response.status_code, 200)

return response

Expand Down Expand Up @@ -205,3 +206,43 @@ def test_last_used_at(self):

self.assertEquals(len(tokens), 1)
self.assertLess(first_used_at, second_used_at)

def test_login_users_only(self):
u1 = User.objects.create_user(username="u1", password="abc123")
o1 = Organization.objects.create_user(
username="o1", password="abc123", organization_owner=u1
)
t1 = Team.objects.create_user(
username="@o1/t1", password="abc123", team_organization=o1
)

# regular users can login
response = self.login("u1", "abc123", success=True)

tokens = u1.auth_tokens.order_by("-created_at").all()

self.assertEquals(len(tokens), 1)
self.assertEquals(o1.auth_tokens.order_by("-created_at").count(), 0)
self.assertEquals(t1.auth_tokens.order_by("-created_at").count(), 0)
self.assertTokenMatch(tokens[0], response.json())
self.assertIsNone(tokens[0].last_used_at)

# organizations cannot login
response = self.login("o1", "abc123", success=False)

self.assertEquals(u1.auth_tokens.order_by("-created_at").count(), 1)
self.assertEquals(o1.auth_tokens.order_by("-created_at").count(), 0)
self.assertEquals(t1.auth_tokens.order_by("-created_at").count(), 0)
self.assertEquals(
response.json(), {"code": "api_error", "message": "API Error"}
)

# teams cannot login
response = self.login("t1", "abc123", success=False)

self.assertEquals(u1.auth_tokens.order_by("-created_at").count(), 1)
self.assertEquals(o1.auth_tokens.order_by("-created_at").count(), 0)
self.assertEquals(t1.auth_tokens.order_by("-created_at").count(), 0)
self.assertEquals(
response.json(), {"code": "api_error", "message": "API Error"}
)
4 changes: 2 additions & 2 deletions docker-app/qfieldcloud/core/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
from datetime import timedelta

from django.conf import settings
from constance import config
from django.utils import timezone
from django_cron import CronJobBase, Schedule
from invitations.utils import get_invitation_model
Expand Down Expand Up @@ -58,7 +58,7 @@ def do(self):
status__in=[Job.Status.QUEUED, Job.Status.STARTED],
# add extra seconds just to make sure a properly finished job properly updated the status.
started_at__lt=timezone.now()
- timedelta(seconds=settings.WORKER_TIMEOUT_S + 10),
- timedelta(seconds=config.WORKER_TIMEOUT_S + 10),
)

for job in jobs:
Expand Down
8 changes: 8 additions & 0 deletions docker-app/qfieldcloud/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ class NotAuthenticatedError(QFieldCloudException):
status_code = status.HTTP_401_UNAUTHORIZED


class PermissionDeniedError(QFieldCloudException):
"""Raised when the user has not the required permission for an action."""

code = "permission_denied"
message = "Permission denied"
status_code = status.HTTP_403_FORBIDDEN


class EmptyContentError(QFieldCloudException):
"""Raised when a request doesn't contain an expected content
(e.g. a file)"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,9 @@ class Migration(migrations.Migration):
sql="\n CREATE TRIGGER core_delta_geom_insert_trigger BEFORE INSERT ON core_delta\n FOR EACH ROW\n EXECUTE FUNCTION core_delta_geom_trigger_func()\n ",
reverse_sql="\n DROP TRIGGER IF EXISTS core_delta_geom_insert_trigger ON core_delta\n ",
),
migrate_sql.operations.CreateSQL(
name="core_user_email_partial_uniq",
sql="\n CREATE UNIQUE INDEX IF NOT EXISTS core_user_email_partial_uniq ON core_user (email)\n WHERE user_type = 1 AND email IS NOT NULL AND email != ''\n ",
reverse_sql="\n DROP TRIGGER IF EXISTS core_delta_geom_insert_trigger ON core_delta\n ",
),
]
5 changes: 3 additions & 2 deletions docker-app/qfieldcloud/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,8 +1073,9 @@ def status(self) -> Status:
if not self.project_filename:
status = Project.Status.FAILED
status_code = Project.StatusCode.FAILED_PROCESS_PROJECTFILE
elif self.is_public or (
max_premium_collaborators_per_private_project != -1
elif (
not self.is_public
and max_premium_collaborators_per_private_project != -1
and max_premium_collaborators_per_private_project
< self.direct_collaborators.count()
):
Expand Down
16 changes: 16 additions & 0 deletions docker-app/qfieldcloud/core/permissions_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ def can_set_delta_status(user: QfcUser, delta: Delta) -> bool:
Delta.Status.CONFLICT,
Delta.Status.NOT_APPLIED,
Delta.Status.ERROR,
Delta.Status.APPLIED,
Delta.Status.IGNORED,
Delta.Status.UNPERMITTED,
):
return False

Expand Down Expand Up @@ -420,6 +423,19 @@ def can_ignore_delta(user: QfcUser, delta: Delta) -> bool:
return True


def can_read_jobs(user: QfcUser, project: Project) -> bool:
return user_has_project_roles(
user,
project,
[
ProjectCollaborator.Roles.ADMIN,
ProjectCollaborator.Roles.MANAGER,
ProjectCollaborator.Roles.EDITOR,
ProjectCollaborator.Roles.REPORTER,
],
)


def can_create_secrets(user: QfcUser, project: Project) -> bool:
return user_has_project_roles(
user,
Expand Down
10 changes: 6 additions & 4 deletions docker-app/qfieldcloud/core/querysets_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from operator import and_, or_

from django.db.models import Q
from django.db.models import Value as V
from django.db.models.functions import StrIndex
from django.db.models.manager import BaseManager
from qfieldcloud.core.models import (
Delta,
Expand Down Expand Up @@ -45,9 +47,7 @@ def get_users(
), "Cannot have the project and organization filters set simultaneously"

if username:
users = User.objects.filter(
Q(username__icontains=username) | Q(email__icontains=username)
)
users = User.objects.filter(username__icontains=username)
else:
users = User.objects.all()

Expand Down Expand Up @@ -100,4 +100,6 @@ def get_users(
else:
users = users.exclude(reduce(or_, [c for c in conditions]))

return users.order_by("-username")
return users.annotate(
ordering=StrIndex("username", V(username)),
).order_by("ordering", "username")
48 changes: 28 additions & 20 deletions docker-app/qfieldcloud/core/rest_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,41 @@

def exception_handler(exc, context):

if isinstance(exc, qfieldcloud_exceptions.QFieldCloudException):
pass
elif isinstance(exc, rest_exceptions.AuthenticationFailed):
exc = qfieldcloud_exceptions.AuthenticationFailedError()
# Map exceptions to qfc exceptions
is_error = False
if isinstance(exc, rest_exceptions.AuthenticationFailed):
qfc_exc = qfieldcloud_exceptions.AuthenticationFailedError()
elif isinstance(exc, rest_exceptions.NotAuthenticated):
exc = qfieldcloud_exceptions.NotAuthenticatedError()
elif isinstance(exc, rest_exceptions.APIException):
exc = qfieldcloud_exceptions.APIError(
status_code=exc.status_code, detail=exc.detail
)
qfc_exc = qfieldcloud_exceptions.NotAuthenticatedError()
elif isinstance(exc, rest_exceptions.PermissionDenied):
qfc_exc = qfieldcloud_exceptions.PermissionDeniedError()
elif isinstance(exc, exceptions.ObjectDoesNotExist):
exc = qfieldcloud_exceptions.ObjectNotFoundError(detail=str(exc))
qfc_exc = qfieldcloud_exceptions.ObjectNotFoundError(detail=str(exc))
elif isinstance(exc, exceptions.ValidationError):
exc = qfieldcloud_exceptions.ValidationError(detail=str(exc))
qfc_exc = qfieldcloud_exceptions.ValidationError(detail=str(exc))
elif isinstance(exc, qfieldcloud_exceptions.QFieldCloudException):
is_error = True
qfc_exc = exc
elif isinstance(exc, rest_exceptions.APIException):
is_error = True
qfc_exc = qfieldcloud_exceptions.APIError(exc.detail, exc.status_code)
else:
# When running tests, we rethrow the exception, so we get a full trace to
# help with debugging
# Unexpected ! We rethrow original exception to make debugging tests easier
if settings.IN_TEST_SUITE:
raise exc
is_error = True
qfc_exc = qfieldcloud_exceptions.QFieldCloudException(detail=str(exc))

if is_error:
# log the original exception
logging.exception(exc)
exc = qfieldcloud_exceptions.QFieldCloudException(detail=str(exc))
else:
# log as info as repeated errors could still indicate an actual issue
logging.info(str(exc))

body = {
"code": exc.code,
"message": exc.message,
"code": qfc_exc.code,
"message": qfc_exc.message,
}

if settings.DEBUG:
Expand All @@ -44,12 +54,10 @@ def exception_handler(exc, context):
"args": context["args"],
"kwargs": context["kwargs"],
"request": str(context["request"]),
"detail": exc.detail,
"detail": qfc_exc.detail,
}

logging.exception(exc)

return Response(
body,
status=exc.status_code,
status=qfc_exc.status_code,
)
10 changes: 10 additions & 0 deletions docker-app/qfieldcloud/core/sql_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,14 @@
DROP TRIGGER IF EXISTS core_delta_geom_insert_trigger ON core_delta
""",
),
SQLItem(
"core_user_email_partial_uniq",
r"""
CREATE UNIQUE INDEX IF NOT EXISTS core_user_email_partial_uniq ON core_user (email)
WHERE user_type = 1 AND email IS NOT NULL AND email != ''
""",
r"""
DROP INDEX IF EXISTS core_user_email_partial_uniq
""",
),
]
51 changes: 48 additions & 3 deletions docker-app/qfieldcloud/core/tests/test_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@
from qfieldcloud.core.models import (
Geodb,
Job,
Organization,
OrganizationMember,
Project,
ProjectCollaborator,
Secret,
Team,
TeamMember,
User,
)
from rest_framework import status
Expand Down Expand Up @@ -596,17 +600,58 @@ def test_collaborator_can_package(self):
)

for idx, role in enumerate(ProjectCollaborator.Roles):
reader = User.objects.create(username=f"user_with_role_{idx}")
u1 = User.objects.create(username=f"user_with_role_{idx}")
ProjectCollaborator.objects.create(
collaborator=reader, project=self.project1, role=role
collaborator=u1, project=self.project1, role=role
)

self.check_package(
token=AuthToken.objects.get_or_create(user=reader)[0],
token=AuthToken.objects.get_or_create(user=u1)[0],
project=self.project1,
expected_files=[
"data.gpkg",
"project_qfield.qgs",
"project_qfield_attachments.zip",
],
)

def test_collaborator_via_team_can_package(self):
u1 = User.objects.create(username="u1")
o1 = Organization.objects.create(username="o1", organization_owner=u1)
p1 = Project.objects.create(
name="p1",
owner=o1,
)
token_u1 = AuthToken.objects.get_or_create(user=u1)[0]

self.upload_files(
token=token_u1,
project=p1,
files=[
("delta/nonspatial.csv", "nonspatial.csv"),
("delta/testdata.gpkg", "testdata.gpkg"),
("delta/points.geojson", "points.geojson"),
("delta/polygons.geojson", "polygons.geojson"),
("delta/project.qgs", "project.qgs"),
],
)

for idx, role in enumerate(ProjectCollaborator.Roles):
team = Team.objects.create(
username=f"@{o1.username}/team_{idx}", team_organization=o1
)
team_user = User.objects.create(username=f"team_user_{idx}")

OrganizationMember.objects.create(member=team_user, organization=o1)
TeamMember.objects.create(member=team_user, team=team)
ProjectCollaborator.objects.create(collaborator=team, project=p1, role=role)

self.check_package(
token=AuthToken.objects.get_or_create(user=team_user)[0],
project=p1,
expected_files=[
"data.gpkg",
"project_qfield.qgs",
"project_qfield_attachments.zip",
],
)
Loading

0 comments on commit ec593d7

Please sign in to comment.