Skip to content

Commit 3611bf0

Browse files
committed
Merge branch 'master' into release
2 parents 6b70b40 + 18ad6c0 commit 3611bf0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1718
-137
lines changed

.env.example

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,23 @@ STORAGES='{
140140
}
141141
}'
142142

143-
# Setting what is the default storage. If empty, it will use the `default` storage.
143+
# Setting what is the default storage for new projects. If empty, it will use the `default` storage.
144144
# NOTE: The value must be a key of the `STORAGES` setting.
145145
# DEFAULT: ""
146146
# STORAGES_PROJECT_DEFAULT_STORAGE=
147147

148+
# Setting what is the default attachments storage for new projects. If empty, it will use the `default` storage.
149+
# NOTE: The value must be a key of the `STORAGES` setting.
150+
# DEFAULT: ""
151+
# STORAGES_PROJECT_DEFAULT_ATTACHMENTS_STORAGE=
152+
153+
# Default value on new projects if attachments should be versioned.
154+
# NOTE: can currently be unset only when using an attachment storage of type WebDAV (qfieldcloud.filestorage.backend.QfcWebDavStorage).
155+
# See definition of the `STORAGES_PROJECT_DEFAULT_ATTACHMENTS_STORAGE` setting.
156+
# VALUES: 0 - not versioned; 1 - versioned
157+
# DEFAULT: 1
158+
# STORAGE_PROJECT_DEFAULT_ATTACHMENTS_VERSIONED=1
159+
148160

149161
##################
150162
# Nginx settings

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
exclude =
33
.git,
44
__pycache__
5-
select = CLB100
5+
select = CLB100, IF100

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ repos:
3434
rev: '7.3.0'
3535
hooks:
3636
- id: flake8
37-
additional_dependencies: [flake8-clean-block]
37+
additional_dependencies: [flake8-clean-block, flake8-if-expr]
3838

3939
# Lint and format
4040
- repo: https://github.com/astral-sh/ruff-pre-commit

docker-app/Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ RUN apt-get update \
1717
libpq-dev \
1818
python3-dev \
1919
gcc \
20-
git \
2120
&& rm -rf /var/lib/apt/lists/*
2221

2322
# install `pip-compile` (as part of `pip-tools`)

docker-app/qfieldcloud/core/adapters.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import traceback
3+
from datetime import timedelta
34
from random import randint
45
from typing import Literal
56

@@ -8,9 +9,12 @@
89
from allauth.account.models import EmailConfirmationHMAC
910
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
1011
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
12+
from constance import config
13+
from django.contrib import messages
1114
from django.contrib.auth.models import AbstractUser
1215
from django.core.exceptions import ValidationError
1316
from django.http import HttpRequest
17+
from django.utils import timezone
1418
from invitations.adapters import BaseInvitationsAdapter
1519

1620
from qfieldcloud.authentication.sso.provider_styles import SSOProviderStyles
@@ -27,6 +31,26 @@ class AccountAdapter(DefaultAccountAdapter, BaseInvitationsAdapter):
2731
to overcome this limitation by providing custom `new_user` method.
2832
"""
2933

34+
def login(self, request, user):
35+
# last_login here is the *previous* login time
36+
previous_last_login = user.last_login
37+
38+
response = super().login(request, user)
39+
40+
threshold = getattr(config, "WEB_USER_INACTIVITY_THRESHOLD_DAYS", 0)
41+
42+
if previous_last_login and threshold > 0:
43+
delta = timezone.now() - previous_last_login
44+
if delta > timedelta(days=threshold):
45+
messages.add_message(
46+
request,
47+
messages.INFO,
48+
"You have been inactive for a while, welcome back!",
49+
extra_tags="inactive-user-modal",
50+
)
51+
52+
return response
53+
3054
def new_user(self, request):
3155
"""
3256
Instantiates a new User instance.

docker-app/qfieldcloud/core/admin.py

Lines changed: 125 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949

5050
from qfieldcloud.core import exceptions
5151
from qfieldcloud.core.models import (
52+
SHARED_DATASETS_PROJECT_NAME,
5253
ApplyJob,
5354
ApplyJobDelta,
5455
Delta,
@@ -71,6 +72,7 @@
7172
from qfieldcloud.core.utils2 import delta_utils, jobs, pg_service_file
7273
from qfieldcloud.filestorage.backend import QfcS3Boto3Storage
7374
from qfieldcloud.filestorage.models import File
75+
from qfieldcloud.subscription.models import get_subscription_model
7476

7577

7678
class QfcAdminSite(AdminSite):
@@ -147,8 +149,37 @@ class ModelAdminEstimateCountMixin:
147149
list_per_page = settings.QFIELDCLOUD_ADMIN_LIST_PER_PAGE
148150

149151

152+
class ModelAdminSearchParserMixin:
153+
"""
154+
Mixin to add search parser to the model admin.
155+
"""
156+
157+
search_parser_config: dict[str, dict[str, Any]] | None = None
158+
159+
def get_search_results(
160+
self, request: HttpRequest, queryset: QuerySet, search_term: str
161+
) -> tuple[QuerySet, bool]:
162+
if self.search_parser_config:
163+
filters = search_parser(
164+
request,
165+
queryset,
166+
search_term,
167+
self.search_parser_config,
168+
)
169+
170+
if filters:
171+
# Bypass standard search to avoid literal matching,
172+
# Return True to enable distinct to handle potential duplicates.
173+
return queryset.filter(**filters), True
174+
175+
return super().get_search_results(request, queryset, search_term) # type: ignore
176+
177+
150178
class QFieldCloudModelAdmin( # type: ignore
151-
ModelAdminNoPkOrderChangeListMixin, ModelAdminEstimateCountMixin, admin.ModelAdmin
179+
ModelAdminNoPkOrderChangeListMixin,
180+
ModelAdminEstimateCountMixin,
181+
ModelAdminSearchParserMixin,
182+
admin.ModelAdmin,
152183
):
153184
def has_delete_permission(self, request, obj=None):
154185
"""Reimplementing this Django Admin method to allow deleting related objects in django admin from another ModelAdmin.
@@ -565,6 +596,26 @@ def save_model(self, request, obj, form, change):
565596
obj.clean()
566597
obj.save()
567598

599+
def change_view(
600+
self,
601+
request: HttpRequest,
602+
object_id: str,
603+
form_url: str = "",
604+
extra_context: Any | None = None,
605+
) -> HttpResponse:
606+
# Add the subscription model is editable flag to the extra context
607+
extra_context = extra_context or {}
608+
609+
extra_context.update(
610+
{
611+
"subscription_model": get_subscription_model(),
612+
}
613+
)
614+
615+
return super().change_view(
616+
request, object_id, form_url, extra_context=extra_context
617+
)
618+
568619
def get_urls(self):
569620
urls = super().get_urls()
570621

@@ -884,6 +935,26 @@ def clean_are_attachments_versioned(self):
884935

885936
return value
886937

938+
def clean(self):
939+
cleaned_data = super().clean()
940+
name = cleaned_data.get("name")
941+
942+
if name and name.lower() == SHARED_DATASETS_PROJECT_NAME:
943+
if (
944+
self.instance.pk
945+
and self.instance.name.lower() == SHARED_DATASETS_PROJECT_NAME
946+
):
947+
pass
948+
949+
elif self.instance.has_the_qgis_file:
950+
raise ValidationError(
951+
_(
952+
"Cannot rename project to '{}' because it contains a QGIS project file."
953+
).format(name)
954+
)
955+
956+
return cleaned_data
957+
887958

888959
class ProjectAdmin(QFieldCloudModelAdmin):
889960
form = ProjectForm
@@ -960,6 +1031,18 @@ class ProjectAdmin(QFieldCloudModelAdmin):
9601031

9611032
change_form_template = "admin/project_change_form.html"
9621033

1034+
search_parser_config = {
1035+
"owner": {
1036+
"filter": "owner__username__iexact",
1037+
},
1038+
"collaborator": {
1039+
"filter": "user_roles__user__username__iexact",
1040+
"extra_filters": {
1041+
"is_public": False,
1042+
},
1043+
},
1044+
}
1045+
9631046
def get_form(self, *args, **kwargs):
9641047
help_texts = {
9651048
"file_storage_bytes": _(
@@ -969,33 +1052,6 @@ def get_form(self, *args, **kwargs):
9691052
kwargs.update({"help_texts": help_texts})
9701053
return super().get_form(*args, **kwargs)
9711054

972-
def get_search_results(self, request, queryset, search_term):
973-
filters = search_parser(
974-
request,
975-
queryset,
976-
search_term,
977-
{
978-
"owner": {
979-
"filter": "owner__username__iexact",
980-
},
981-
"collaborator": {
982-
"filter": "user_roles__user__username__iexact",
983-
"extra_filters": {
984-
"is_public": False,
985-
},
986-
},
987-
},
988-
)
989-
990-
if filters:
991-
return queryset.filter(**filters), True
992-
993-
queryset, use_distinct = super().get_search_results(
994-
request, queryset, search_term
995-
)
996-
997-
return queryset, use_distinct
998-
9991055
def project_files(self, instance):
10001056
return instance.pk
10011057

@@ -1107,6 +1163,12 @@ class JobAdmin(QFieldCloudModelAdmin):
11071163

11081164
change_form_template = "admin/job_change_form.html"
11091165

1166+
search_parser_config = {
1167+
"created_by": {
1168+
"filter": "created_by__username__iexact",
1169+
},
1170+
}
1171+
11101172
def get_queryset(self, request):
11111173
return super().get_queryset(request).defer("output", "feedback")
11121174

@@ -1325,6 +1387,12 @@ class DeltaAdmin(QFieldCloudModelAdmin):
13251387

13261388
change_form_template = "admin/delta_change_form.html"
13271389

1390+
search_parser_config = {
1391+
"created_by": {
1392+
"filter": "created_by__username__iexact",
1393+
},
1394+
}
1395+
13281396
def old_geom_truncated(self, instance):
13291397
return self.geom_truncated(instance.old_geom)
13301398

@@ -1333,7 +1401,10 @@ def new_geom_truncated(self, instance):
13331401

13341402
# Show geometries only truncated as they are fully shown in content
13351403
def geom_truncated(self, geom):
1336-
return f"{str(geom)[:70]} ..." if geom else "-"
1404+
if geom:
1405+
return f"{str(geom)[:70]} ..."
1406+
else:
1407+
return "-"
13371408

13381409
# This will disable add functionality
13391410

@@ -1487,6 +1558,15 @@ class OrganizationAdmin(QFieldCloudModelAdmin):
14871558

14881559
autocomplete_fields = ("organization_owner",)
14891560

1561+
search_parser_config = {
1562+
"owner": {
1563+
"filter": "organization_owner__username__iexact",
1564+
},
1565+
"member": {
1566+
"filter": "membership_roles__user__username__iexact",
1567+
},
1568+
}
1569+
14901570
@admin.display(description=_("Active members"))
14911571
def active_users_links(self, instance) -> str:
14921572
persons = instance.useraccount.current_subscription.active_users
@@ -1516,30 +1596,6 @@ def storage_usage__field(self, instance) -> str:
15161596
used_storage_perc = instance.useraccount.storage_used_ratio * 100
15171597
return f"{used_storage} {free_storage} ({used_storage_perc:.2f}%)"
15181598

1519-
def get_search_results(self, request, queryset, search_term):
1520-
filters = search_parser(
1521-
request,
1522-
queryset,
1523-
search_term,
1524-
{
1525-
"owner": {
1526-
"filter": "organization_owner__username__iexact",
1527-
},
1528-
"member": {
1529-
"filter": "membership_roles__user__username__iexact",
1530-
},
1531-
},
1532-
)
1533-
1534-
if filters:
1535-
return queryset.filter(**filters), True
1536-
1537-
queryset, use_distinct = super().get_search_results(
1538-
request, queryset, search_term
1539-
)
1540-
1541-
return queryset, use_distinct
1542-
15431599
def save_formset(self, request, form, formset, change):
15441600
for form_obj in formset:
15451601
if isinstance(form_obj.instance, OrganizationMember):
@@ -1644,10 +1700,19 @@ def lookups(self, request, model_admin):
16441700

16451701

16461702
class LogEntryAdmin(
1647-
ModelAdminNoPkOrderChangeListMixin, ModelAdminEstimateCountMixin, BaseLogEntryAdmin
1703+
ModelAdminNoPkOrderChangeListMixin,
1704+
ModelAdminEstimateCountMixin,
1705+
ModelAdminSearchParserMixin,
1706+
BaseLogEntryAdmin,
16481707
):
16491708
list_filter = ("action", QFieldCloudResourceTypeFilter)
16501709

1710+
search_parser_config = {
1711+
"user": {
1712+
"filter": "actor__username__iexact",
1713+
},
1714+
}
1715+
16511716

16521717
class FaultyDeltaFilesAdmin(QFieldCloudModelAdmin):
16531718
list_display = (
@@ -1681,6 +1746,12 @@ class FaultyDeltaFilesAdmin(QFieldCloudModelAdmin):
16811746

16821747
exclude = ("traceback",)
16831748

1749+
search_parser_config = {
1750+
"owner": {
1751+
"filter": "project__owner__username__iexact",
1752+
},
1753+
}
1754+
16841755
def traceback__pre(self, instance) -> str:
16851756
return format_pre(instance.traceback)
16861757

docker-app/qfieldcloud/core/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,12 @@ class InvalidRangeError(QFieldCloudException):
200200
code = "invalid_http_range"
201201
message = "The provided HTTP range header is invalid."
202202
status_code = status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE
203+
204+
205+
class QGISProjectFileNotAllowedError(QFieldCloudException):
206+
"""Raised when a QGIS project file is uploaded to a project that does not allow it
207+
(e.g. shared datasets project)"""
208+
209+
code = "qgis_project_file_not_allowed"
210+
message = "QGIS project files are not allowed in this project."
211+
status_code = status.HTTP_400_BAD_REQUEST

0 commit comments

Comments
 (0)