diff --git a/docker-app/qfieldcloud/core/exceptions.py b/docker-app/qfieldcloud/core/exceptions.py index 70a7e7758..d27b5421d 100644 --- a/docker-app/qfieldcloud/core/exceptions.py +++ b/docker-app/qfieldcloud/core/exceptions.py @@ -1,4 +1,3 @@ -from deprecated import deprecated from rest_framework import status @@ -177,12 +176,3 @@ class ProjectAlreadyExistsError(QFieldCloudException): code = "project_already_exists" message = "This user already owns a project with the same name." status_code = status.HTTP_400_BAD_REQUEST - - -@deprecated("moved to subscription") -class ReachedMaxOrganizationMembersError(QFieldCloudException): - """Raised when an organization has exhausted its quota of members""" - - code = "organization_has_max_number_of_members" - message = "Cannot add new organization members, account limit has been reached." - status_code = status.HTTP_403_FORBIDDEN diff --git a/docker-app/qfieldcloud/core/middleware/requests.py b/docker-app/qfieldcloud/core/middleware/requests.py index e470877b2..7a29f2f9c 100644 --- a/docker-app/qfieldcloud/core/middleware/requests.py +++ b/docker-app/qfieldcloud/core/middleware/requests.py @@ -1,17 +1,44 @@ +import io +import logging +import shutil + +from constance import config +from django.conf import settings + +logger = logging.getLogger(__name__) + + def attach_keys(get_response): """ QF-2540 - Annotate request with a str representation of relevant fields, so as to obtain a diff - by comparing with the post-serialized request later in the callstack. + Annotate request with: + - a `str` representation of relevant fields, so as to obtain a diff by comparing with the post-serialized request later in the callstack; + - a byte-for-byte, non stealing copy of the raw body to inspect multipart boundaries. """ def middleware(request): + # add a copy of the request body to the request + if ( + settings.SENTRY_DSN + and request.method == "POST" + and "Content-Length" in request.headers + and ( + int(request.headers["Content-Length"]) + < config.SENTRY_REQUEST_MAX_SIZE_TO_SEND + ) + ): + logger.info("Making a temporary copy for request body.") + + input_stream = io.BytesIO(request.body) + output_stream = io.BytesIO() + shutil.copyfileobj(input_stream, output_stream) + request.body_stream = output_stream + request_attributes = { "file_key": str(request.FILES.keys()), "meta": str(request.META), + "files": request.FILES.getlist("file"), } - if "file" in request.FILES.keys(): - request_attributes["files"] = request.FILES.getlist("file") request.attached_keys = str(request_attributes) response = get_response(request) return response diff --git a/docker-app/qfieldcloud/core/models.py b/docker-app/qfieldcloud/core/models.py index 78a722161..20b5ae6ae 100644 --- a/docker-app/qfieldcloud/core/models.py +++ b/docker-app/qfieldcloud/core/models.py @@ -415,11 +415,6 @@ def current_subscription(self): Subscription = get_subscription_model() return Subscription.get_or_create_current_subscription(self) - @property - @deprecated("Use `current_subscription` instead") - def active_subscription(self): - return self.current_subscription() - @property def upcoming_subscription(self): from qfieldcloud.subscription.models import get_subscription_model @@ -437,13 +432,6 @@ def avatar_url(self): else: return None - @property - @deprecated("Use `UserAccount().storage_used_bytes` instead") - # TODO delete this method after refactoring tests so it's no longer used there - def storage_used_mb(self) -> float: - """Returns the storage used in MB""" - return self.storage_used_bytes / 1000 / 1000 - @property def storage_used_bytes(self) -> float: """Returns the storage used in bytes""" @@ -457,14 +445,6 @@ def storage_used_bytes(self) -> float: return used_quota - @property - @deprecated("Use `UserAccount().storage_free_bytes` instead") - # TODO delete this method after refactoring tests so it's no longer used there - def storage_free_mb(self) -> float: - """Returns the storage quota left in MB (quota from account and packages minus storage of all owned projects)""" - - return self.storage_free_bytes / 1000 / 1000 - @property def storage_free_bytes(self) -> float: """Returns the storage quota left in bytes (quota from account and packages minus storage of all owned projects)""" @@ -1498,7 +1478,12 @@ def clean(self) -> None: if self.collaborator.is_person: members_qs = organization.members.filter(member=self.collaborator) - if not members_qs.exists(): + # for organizations-owned projects, the candidate collaborator + # must be a member of the organization or the organization's owner + if not ( + members_qs.exists() + or self.collaborator == organization.organization_owner + ): raise ValidationError( _( "Cannot add a user who is not a member of the organization as a project collaborator." diff --git a/docker-app/qfieldcloud/core/permissions_utils.py b/docker-app/qfieldcloud/core/permissions_utils.py index 1b704cf6d..b79479677 100644 --- a/docker-app/qfieldcloud/core/permissions_utils.py +++ b/docker-app/qfieldcloud/core/permissions_utils.py @@ -1,6 +1,5 @@ from typing import List, Literal, Union -from deprecated import deprecated from django.utils.translation import gettext as _ from qfieldcloud.authentication.models import AuthToken from qfieldcloud.core.models import ( @@ -336,33 +335,6 @@ def can_apply_pending_deltas_for_project(user: QfcUser, project: Project) -> boo ) -@deprecated("Use `can_set_delta_status_for_project` instead") -def can_apply_deltas(user: QfcUser, project: Project) -> bool: - return user_has_project_roles( - user, - project, - [ - ProjectCollaborator.Roles.ADMIN, - ProjectCollaborator.Roles.MANAGER, - ProjectCollaborator.Roles.EDITOR, - ProjectCollaborator.Roles.REPORTER, - ], - ) - - -@deprecated("Use `can_set_delta_status_for_project` instead") -def can_overwrite_deltas(user: QfcUser, project: Project) -> bool: - return user_has_project_roles( - user, - project, - [ - ProjectCollaborator.Roles.ADMIN, - ProjectCollaborator.Roles.MANAGER, - ProjectCollaborator.Roles.EDITOR, - ], - ) - - def can_set_delta_status_for_project(user: QfcUser, project: Project) -> bool: return user_has_project_roles( user, @@ -414,47 +386,6 @@ def can_create_delta(user: QfcUser, delta: Delta) -> bool: return False -@deprecated("Use `can_set_delta_status` instead") -def can_retry_delta(user: QfcUser, delta: Delta) -> bool: - if not can_apply_deltas(user, delta.project): - return False - - if delta.last_status not in ( - Delta.Status.CONFLICT, - Delta.Status.NOT_APPLIED, - Delta.Status.ERROR, - ): - return False - - return True - - -@deprecated("Use `can_set_delta_status` instead") -def can_overwrite_delta(user: QfcUser, delta: Delta) -> bool: - if not can_overwrite_deltas(user, delta.project): - return False - - if delta.last_status not in (Delta.Status.CONFLICT): - return False - - return True - - -@deprecated("Use `can_set_delta_status` instead") -def can_ignore_delta(user: QfcUser, delta: Delta) -> bool: - if not can_apply_deltas(user, delta.project): - return False - - if delta.last_status not in ( - Delta.Status.CONFLICT, - Delta.Status.NOT_APPLIED, - Delta.Status.ERROR, - ): - return False - - return True - - def can_read_jobs(user: QfcUser, project: Project) -> bool: return user_has_project_roles( user, diff --git a/docker-app/qfieldcloud/core/templates/admin/project_files_widget.html b/docker-app/qfieldcloud/core/templates/admin/project_files_widget.html index 32c561a78..a14858198 100644 --- a/docker-app/qfieldcloud/core/templates/admin/project_files_widget.html +++ b/docker-app/qfieldcloud/core/templates/admin/project_files_widget.html @@ -247,8 +247,8 @@ }); $deleteBtn.addEventListener('click', () => { - const passPhrase = 'Here be dragons!'; - const confirmation = prompt(`Are you sure you want to delete file "${file.name}"? This operation is irreversible, the file is deleted forever and the project may be damaged forever! Type "${passPhrase}" to confirm your destructive action!`); + const passPhrase = file.name; + const confirmation = prompt(`Are you sure you want to delete file "${passPhrase}"? This operation is irreversible, the file is deleted forever and the project may be damaged forever! Type "${passPhrase}" to confirm your destructive action!`); if (confirmation !== passPhrase) { $dialog.close(); diff --git a/docker-app/qfieldcloud/core/tests/test_sentry.py b/docker-app/qfieldcloud/core/tests/test_sentry.py index ed782c346..512fa999e 100644 --- a/docker-app/qfieldcloud/core/tests/test_sentry.py +++ b/docker-app/qfieldcloud/core/tests/test_sentry.py @@ -1,15 +1,27 @@ -from io import StringIO -from os import environ +from io import BytesIO, StringIO from unittest import skipIf -from rest_framework.test import APITestCase +from django.conf import settings +from django.test import Client, TestCase from ..utils2.sentry import report_serialization_diff_to_sentry -class QfcTestCase(APITestCase): +class QfcTestCase(TestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + # Let's set up a WSGI request the body of which we'll extract + response = Client().post( + "test123", + data={"file": BytesIO(b"Hello World")}, + format="multipart", + ) + request = response.wsgi_request + cls.body_stream = BytesIO(request.read()) + @skipIf( - environ.get("SENTRY_DSN", False), + not settings.SENTRY_DSN, "Do not run this test when Sentry's DSN is not set.", ) def test_logging_with_sentry(self): @@ -42,7 +54,8 @@ def test_logging_with_sentry(self): } ), "buffer": StringIO("The traceback of the exception to raise"), - "capture_message": True, # so that sentry receives attachments even when there's no exception/event + "body_stream": self.body_stream, + "capture_message": True, # so that sentry receives attachments even when there's no exception/event, } will_be_sent = report_serialization_diff_to_sentry(**mock_payload) self.assertTrue(will_be_sent) diff --git a/docker-app/qfieldcloud/core/utils2/projects.py b/docker-app/qfieldcloud/core/utils2/projects.py index 14e49d606..bd150838b 100644 --- a/docker-app/qfieldcloud/core/utils2/projects.py +++ b/docker-app/qfieldcloud/core/utils2/projects.py @@ -1,26 +1,25 @@ -from typing import Tuple - from django.db.models import Q from django.utils.translation import gettext as _ from qfieldcloud.core import invitations_utils as invitation from qfieldcloud.core import permissions_utils as perms -from qfieldcloud.core.models import Person, Project, ProjectCollaborator, Team, User +from qfieldcloud.core.models import Person, Project, ProjectCollaborator, Team def create_collaborator( - project: Project, user: User, created_by: Person -) -> Tuple[bool, str]: + project: Project, user: Person | Team, created_by: Person +) -> tuple[bool, str]: """Creates a new collaborator (qfieldcloud.core.ProjectCollaborator) if possible Args: project (Project): the project to add collaborator to - user (User): the user to be added as collaborator + user (Person | Team): the user to be added as collaborator created_by (Person): the user that initiated the collaborator creation Returns: - Tuple[bool, str]: success, message - whether the collaborator creation was success and explanation message of the outcome + tuple[bool, str]: success, message - whether the collaborator creation was success and explanation message of the outcome """ success, message = False, "" + user_type_name = "Team" if isinstance(user, Team) else "User" try: perms.check_can_become_collaborator(user, project) @@ -34,12 +33,14 @@ def create_collaborator( updated_by=created_by, ) success = True - message = _('User "{}" has been invited to the project.').format(user.username) + message = _( + f'{user_type_name} "{user.username}" has been invited to the project.' + ) except perms.UserOrganizationRoleError: message = _( - "User '{}' is not a member of the organization that owns the project. " - "Please add this user to the organization first." - ).format(user.username) + f"{user_type_name} '{user.username}' is not a member of the organization that owns the project. " + f"Please add this {user_type_name.lower()} to the organization first." + ) except ( perms.AlreadyCollaboratorError, perms.ReachedCollaboratorLimitError, diff --git a/docker-app/qfieldcloud/core/utils2/sentry.py b/docker-app/qfieldcloud/core/utils2/sentry.py index 00d14bd75..707e0078b 100644 --- a/docker-app/qfieldcloud/core/utils2/sentry.py +++ b/docker-app/qfieldcloud/core/utils2/sentry.py @@ -1,5 +1,5 @@ import logging -from io import StringIO +from io import BytesIO, StringIO import sentry_sdk @@ -11,6 +11,7 @@ def report_serialization_diff_to_sentry( pre_serialization: str, post_serialization: str, buffer: StringIO, + body_stream: BytesIO | None, capture_message=False, ) -> bool: """ @@ -20,10 +21,13 @@ def report_serialization_diff_to_sentry( pre_serialization: str representing the request `files` keys and meta information before serialization and middleware. post_serialization: str representing the request `files` keys and meta information after serialization and middleware. buffer: StringIO buffer from which to extract traceback capturing callstack ahead of the calling function. + bodystream: BytesIO buffer capturing the request's raw body. capture_message: bool used as a flag by the caller to create an extra event against Sentry to attach the files to. """ with sentry_sdk.configure_scope() as scope: try: + logger.info("Sending explicit sentry report!") + filename = f"{name}_contents.txt" scope.add_attachment( bytes=bytes( @@ -38,10 +42,16 @@ def report_serialization_diff_to_sentry( bytes=bytes(buffer.getvalue(), encoding="utf8"), filename=filename, ) + + if body_stream: + filename = f"{name}_rawbody.txt" + scope.add_attachment(bytes=body_stream.getvalue(), filename=filename) + if capture_message: - sentry_sdk.capture_message("Sending to Sentry...", scope=scope) + sentry_sdk.capture_message("Explicit Sentry report!", scope=scope) return True + except Exception as error: + logger.error(f"Unable to send file to Sentry: failed on {error}") sentry_sdk.capture_exception(error) - logging.error(f"Unable to send file to Sentry: failed on {error}") return False diff --git a/docker-app/qfieldcloud/core/views/files_views.py b/docker-app/qfieldcloud/core/views/files_views.py index eec4f1a42..a564a6d74 100644 --- a/docker-app/qfieldcloud/core/views/files_views.py +++ b/docker-app/qfieldcloud/core/views/files_views.py @@ -26,7 +26,7 @@ ) from rest_framework import permissions, serializers, status, views from rest_framework.exceptions import NotFound -from rest_framework.parsers import MultiPartParser +from rest_framework.parsers import DataAndFiles, MultiPartParser from rest_framework.request import Request from rest_framework.response import Response @@ -83,10 +83,11 @@ def get(self, request: Request, projectid: str) -> Response: path = PurePath(version.key) filename = str(path.relative_to(*path.parts[:3])) last_modified = version.last_modified.strftime("%d.%m.%Y %H:%M:%S %Z") + md5sum = version.e_tag.replace('"', "") version_data = { "size": version.size, - "md5sum": version.e_tag.replace('"', ""), + "md5sum": md5sum, "version_id": version.version_id, "last_modified": last_modified, "is_latest": version.is_latest, @@ -119,7 +120,7 @@ def get(self, request: Request, projectid: str) -> Response: files[version.key]["name"] = filename files[version.key]["size"] = version.size - files[version.key]["md5sum"] = version.e_tag.replace('"', "") + files[version.key]["md5sum"] = md5sum files[version.key]["last_modified"] = last_modified files[version.key]["is_attachment"] = is_attachment @@ -147,6 +148,23 @@ def has_permission(self, request, view): return False +class QfcMultiPartSerializer(MultiPartParser): + + errors: list[str] = [] + + # QF-2540 + def parse(self, stream, media_type=None, parser_context=None) -> DataAndFiles: + """Substitute to MultiPartParser for debugging `EmptyContentError`""" + parsed: DataAndFiles = super().parse(stream, media_type, parser_context) + + if "file" not in parsed.files or not parsed.files["file"]: + self.errors.append( + f"QfcMultiPartParser was able to obtain `DataAndFiles` from the request's input stream, but `MultiValueDict` either lacks a `file` key or a value at `file`! parser_context: {parser_context}. `EmptyContentError` expected." + ) + + return parsed + + @extend_schema_view( get=extend_schema( description="Download a file from a project", @@ -171,7 +189,7 @@ def has_permission(self, request, view): class DownloadPushDeleteFileView(views.APIView): # TODO: swagger doc # TODO: docstring - parser_classes = [MultiPartParser] + parser_classes = [QfcMultiPartSerializer] permission_classes = [ permissions.IsAuthenticated, DownloadPushDeleteFileViewPermissions, @@ -203,31 +221,35 @@ def post(self, request, projectid, filename, format=None): # QF-2540 # Getting traceback in case the traceback provided by Sentry is too short # Add post-serialization keys for diff-ing with pre-serialization keys - buffer = io.StringIO() - print_stack(limit=50, file=buffer) - request_attributes = { - "data": str(copy.copy(self.request.data).keys()), - "files": str(self.request.FILES.keys()), - "meta": str(self.request.META), - } - missing_error = "" + if "file" not in request.data and not request.FILES.getlist("file"): + if "file" not in request.data: + logger.warning( + 'The key "file" was not found in `request.data`. Sending report to Sentry.' + ) - if "file" not in request.data: - missing_error = 'The key "file" was not found in `request.data`. Sending report to Sentry.' + if not request.FILES.getlist("file"): + logger.warning( + 'The key "file" occurs in `request.data` but maps to an empty list. Sending report to Sentry.' + ) - if not request.FILES.getlist("file"): - missing_error = 'The key "file" occurs in `request.data` but maps to an empty list. Sending report to Sentry.' + callstack_buffer = io.StringIO() + print_stack(limit=50, file=callstack_buffer) - if missing_error: - logging.warning(missing_error) + request_attributes = { + "data": str(copy.copy(self.request.data).keys()), + "files": str(self.request.FILES.keys()), + "meta": str(self.request.META), + } # QF-2540 report_serialization_diff_to_sentry( # using the 'X-Request-Id' added to the request by RequestIDMiddleware name=f"{request.META.get('X-Request-Id')}_{projectid}", pre_serialization=request.attached_keys, - post_serialization=str(request_attributes), - buffer=buffer, + post_serialization=",".join(QfcMultiPartSerializer.errors) + + str(request_attributes), + buffer=callstack_buffer, + body_stream=getattr(request, "body_stream", None), ) raise exceptions.EmptyContentError() diff --git a/docker-app/qfieldcloud/notifs/cron.py b/docker-app/qfieldcloud/notifs/cron.py index d3c022806..baeaedcce 100644 --- a/docker-app/qfieldcloud/notifs/cron.py +++ b/docker-app/qfieldcloud/notifs/cron.py @@ -1,4 +1,5 @@ import logging +import os from django.conf import settings from django.core.mail import send_mail @@ -43,12 +44,14 @@ def do(self): logging.warning(f"{user} has notifications, but no email set !") continue + QFIELDCLOUD_HOST = os.environ.get("QFIELDCLOUD_HOST") + logging.debug(f"Sending an email to {user} !") context = { "notifs": notifs, "username": user.username, - "hostname": settings.ALLOWED_HOSTS[0], + "hostname": QFIELDCLOUD_HOST, } subject = render_to_string( diff --git a/docker-app/qfieldcloud/settings.py b/docker-app/qfieldcloud/settings.py index ed92b7ef5..9f543ed97 100644 --- a/docker-app/qfieldcloud/settings.py +++ b/docker-app/qfieldcloud/settings.py @@ -320,6 +320,10 @@ def before_send(event, hint): # environment=ENVIRONMENT, ) +# QF-2704 +# Flag to turn on/off byte-for-byte copy of request's body +# Only requests with a < 10MB body will be reported +SENTRY_REPORT_FULL_BODY = True # Django allauth configurations # https://django-allauth.readthedocs.io/en/latest/configuration.html @@ -457,6 +461,10 @@ def before_send(event, hint): "1000m", "Maximum memory for each QGIS worker container.", ), + "SENTRY_REQUEST_MAX_SIZE_TO_SEND": ( + 0, + "Maximum request size to send the raw request to Sentry. Value 0 disables the raw request copy.", + ), "WORKER_QGIS_CPU_SHARES": ( 512, "Share of CPUs for each QGIS worker container. By default all containers have value 1024 set by docker.", @@ -477,6 +485,7 @@ def before_send(event, hint): "WORKER_QGIS_MEMORY_LIMIT", "WORKER_QGIS_CPU_SHARES", ), + "Debug": ("SENTRY_REQUEST_MAX_SIZE_TO_SEND",), "Subscription": ("TRIAL_PERIOD_DAYS",), } diff --git a/docker-app/qfieldcloud/subscription/models.py b/docker-app/qfieldcloud/subscription/models.py index fd3f3c7c3..2386c22fb 100644 --- a/docker-app/qfieldcloud/subscription/models.py +++ b/docker-app/qfieldcloud/subscription/models.py @@ -368,10 +368,6 @@ def current(self): return qs - @deprecated("Use `current` instead. Remove this once parent repo uses current") - def active(self): - return self.current() - def managed_by(self, user_id: int): """Returns all subscriptions that are managed by given `user_id`. It means the owner personal account and all organizations they own. @@ -698,11 +694,6 @@ def get_or_create_current_subscription(cls, account: UserAccount) -> "Subscripti return subscription - @property - @deprecated("Use `get_or_create_current_subscription` instead") - def get_or_create_active_subscription(cls, account: UserAccount) -> "Subscription": - return cls.get_or_create_current_subscription(account) - @classmethod def get_upcoming_subscription(cls, account: UserAccount) -> "Subscription": result = ( diff --git a/docker-app/qfieldcloud/subscription/tests/test_package.py b/docker-app/qfieldcloud/subscription/tests/test_package.py index 0aa23f102..fdb83cdb8 100644 --- a/docker-app/qfieldcloud/subscription/tests/test_package.py +++ b/docker-app/qfieldcloud/subscription/tests/test_package.py @@ -107,8 +107,12 @@ def assertStorage( user.useraccount.current_subscription.future_storage_package_changed_mb, future_storage_package_changed_mb, ) - self.assertEqual(user.useraccount.storage_used_mb, storage_used_mb) - self.assertEqual(user.useraccount.storage_free_mb, storage_free_mb) + self.assertEqual( + user.useraccount.storage_used_bytes, storage_used_mb * 1000 * 1000 + ) + self.assertEqual( + user.useraccount.storage_free_bytes, storage_free_mb * 1000 * 1000 + ) def test_get_storage_package_type(self): PackageType.objects.all().delete() diff --git a/docker-app/worker_wrapper/wrapper.py b/docker-app/worker_wrapper/wrapper.py index a9c5eeb44..f70e97538 100644 --- a/docker-app/worker_wrapper/wrapper.py +++ b/docker-app/worker_wrapper/wrapper.py @@ -7,7 +7,7 @@ import uuid from datetime import timedelta from pathlib import Path -from typing import Any, Dict, Iterable, List, Tuple +from typing import Any, Iterable import docker import requests @@ -45,6 +45,7 @@ DOCKER_SIGKILL_EXIT_CODE = 137 QGIS_CONTAINER_NAME = os.environ.get("QGIS_CONTAINER_NAME", None) QFIELDCLOUD_HOST = os.environ.get("QFIELDCLOUD_HOST", None) +TMP_FILE = Path("/tmp") assert QGIS_CONTAINER_NAME assert QFIELDCLOUD_HOST @@ -63,9 +64,9 @@ def __init__(self, job_id: str) -> None: try: self.job_id = job_id self.job = self.job_class.objects.select_related().get(id=job_id) - self.shared_tempdir = Path(tempfile.mkdtemp(dir="/tmp")) + self.shared_tempdir = Path(tempfile.mkdtemp(dir=TMP_FILE)) except Exception as err: - feedback: Dict[str, Any] = {} + feedback: dict[str, Any] = {} (_type, _value, tb) = sys.exc_info() feedback["error"] = str(err) feedback["error_origin"] = "worker_wrapper" @@ -82,7 +83,7 @@ def __init__(self, job_id: str) -> None: else: logger.critical(msg, exc_info=err) - def get_context(self) -> Dict[str, Any]: + def get_context(self) -> dict[str, Any]: context = model_to_dict(self.job) for key, value in model_to_dict(self.job.project).items(): @@ -92,10 +93,9 @@ def get_context(self) -> Dict[str, Any]: return context - def get_command(self) -> List[str]: - return [ - p % self.get_context() for p in ["python3", "entrypoint.py", *self.command] - ] + def get_command(self) -> list[str]: + context = self.get_context() + return [p % context for p in ["python3", "entrypoint.py", *self.command]] def before_docker_run(self) -> None: pass @@ -231,8 +231,8 @@ def run(self): ) def _run_docker( - self, command: List[str], volumes: List[str], run_opts: Dict[str, Any] = {} - ) -> Tuple[int, bytes]: + self, command: list[str], volumes: list[str], run_opts: dict[str, Any] = {} + ) -> tuple[int, bytes]: QGIS_CONTAINER_NAME = os.environ.get("QGIS_CONTAINER_NAME", None) QFIELDCLOUD_HOST = os.environ.get("QFIELDCLOUD_HOST", None) QFIELDCLOUD_WORKER_QFIELDCLOUD_URL = os.environ.get( @@ -421,7 +421,7 @@ def __init__(self, job_id: str) -> None: if self.job.overwrite_conflicts: self.command = [*self.command, "--overwrite-conflicts"] - def _prepare_deltas(self, deltas: Iterable[Delta]): + def _prepare_deltas(self, deltas: Iterable[Delta]) -> dict[str, Any]: delta_contents = [] delta_client_ids = [] @@ -533,7 +533,7 @@ class ProcessProjectfileJobRun(JobRun): "%(project__project_filename)s", ] - def get_context(self, *args) -> Dict[str, Any]: + def get_context(self, *args) -> dict[str, Any]: context = super().get_context(*args) if not context.get("project__project_filename"): @@ -573,7 +573,7 @@ def after_docker_exception(self) -> None: def cancel_orphaned_workers(): client: DockerClient = docker.from_env() - running_workers: List[Container] = client.containers.list( + running_workers: list[Container] = client.containers.list( filters={"label": f"app={settings.ENVIRONMENT}_worker"}, ) diff --git a/docker-qgis/entrypoint.py b/docker-qgis/entrypoint.py index 3189a4bda..06ec2c7d4 100755 --- a/docker-qgis/entrypoint.py +++ b/docker-qgis/entrypoint.py @@ -4,7 +4,7 @@ import logging import os from pathlib import Path -from typing import Dict, Union +from typing import Union import qfieldcloud.qgis.apply_deltas import qfieldcloud.qgis.process_projectfile @@ -135,13 +135,13 @@ def _call_qfieldsync_packager(project_filename: Path, package_dir: Path) -> str: return packaged_project_filename -def _extract_layer_data(project_filename: Union[str, Path]) -> Dict: +def _extract_layer_data(project_filename: Union[str, Path]) -> dict: logging.info("Extracting QGIS project layer data…") project_filename = str(project_filename) project = QgsProject.instance() project.read(project_filename) - layers_by_id = get_layers_data(project) + layers_by_id: dict = get_layers_data(project) logging.info( f"QGIS project layer data\n{layers_data_to_string(layers_by_id)}", @@ -150,7 +150,7 @@ def _extract_layer_data(project_filename: Union[str, Path]) -> Dict: return layers_by_id -def cmd_package_project(args): +def cmd_package_project(args: argparse.Namespace): workflow = Workflow( id="package_project", name="Package Project", @@ -228,7 +228,7 @@ def cmd_package_project(args): ) -def cmd_apply_deltas(args): +def cmd_apply_deltas(args: argparse.Namespace): workflow = Workflow( id="apply_changes", name="Apply Changes", @@ -286,7 +286,7 @@ def cmd_apply_deltas(args): ) -def cmd_process_projectfile(args): +def cmd_process_projectfile(args: argparse.Namespace): workflow = Workflow( id="process_projectfile", name="Process Projectfile", @@ -399,5 +399,5 @@ def cmd_process_projectfile(args): ) parser_process_projectfile.set_defaults(func=cmd_process_projectfile) - args = parser.parse_args() + args: argparse.Namespace = parser.parse_args() args.func(args)