Skip to content

Commit 7e437f2

Browse files
authored
Merge pull request #1087 from opengisch/master
QFieldCloud release v0.30.0
2 parents a3cc4f4 + 247ce82 commit 7e437f2

40 files changed

+1270
-952
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ MINIO_BROWSER_PORT=8010
6363
WEB_HTTP_PORT=80
6464
WEB_HTTPS_PORT=443
6565

66+
# Messages are logged at the specified level and all more severe levels. The nginx default is `error`. Read more on https://nginx.org/en/docs/ngx_core_module.html#error_log.
67+
# OPTIONS: debug, info, notice, warn, error, crit, alert, emerg
68+
# DEFAULT: error
69+
NGINX_ERROR_LOG_LEVEL=error
70+
6671
POSTGRES_USER=qfieldcloud_db_admin
6772
POSTGRES_PASSWORD=3shJDd2r7Twwkehb
6873
POSTGRES_DB=qfieldcloud_db

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ jobs:
2727
with:
2828
python-version: '3.12'
2929
- name: Pre-commit
30+
env:
31+
# set the `no-commit-to-branch` to be skipped, otherwise the check fails when we merge to `master` branch
32+
SKIP: no-commit-to-branch
3033
uses: pre-commit/[email protected]
3134

3235
test:

.pre-commit-config.yaml

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
repos:
2-
# Fix end of files
32
- repo: https://github.com/pre-commit/pre-commit-hooks
4-
rev: v4.6.0
3+
rev: v5.0.0
54
hooks:
6-
- id: trailing-whitespace
5+
- id: check-ast
6+
files: '.*\.py$'
7+
- id: check-case-conflict
8+
- id: check-executables-have-shebangs
9+
- id: check-illegal-windows-names
10+
- id: check-json
11+
- id: check-merge-conflict
12+
- id: check-symlinks
13+
- id: check-toml
14+
- id: check-yaml
15+
- id: debug-statements
16+
- id: destroyed-symlinks
717
- id: end-of-file-fixer
18+
- id: fix-byte-order-marker
19+
- id: forbid-new-submodules
820
- id: mixed-line-ending
921
args:
1022
- '--fix=lf'
23+
- id: no-commit-to-branch
24+
- id: trailing-whitespace
1125

1226
- repo: https://github.com/adamchainz/django-upgrade
13-
rev: "1.21.0"
27+
rev: '1.22.2'
1428
hooks:
1529
- id: django-upgrade
16-
args: [--target-version, "4.2", "--skip", "admin_register"]
17-
files: "^docker-app/qfieldcloud/.*.py$"
30+
args: [--target-version, '4.2', '--skip', 'admin_register']
31+
files: '^docker-app/qfieldcloud/.*.py$'
1832

1933
# Lint and format
2034
- repo: https://github.com/astral-sh/ruff-pre-commit
2135
# Ruff version.
22-
rev: v0.6.3
36+
rev: v0.8.1
2337
hooks:
2438
# Run the linter.
2539
- id: ruff
@@ -30,7 +44,7 @@ repos:
3044

3145
# Static type-checking with mypy
3246
- repo: https://github.com/pre-commit/mirrors-mypy
33-
rev: 'v1.11.2'
47+
rev: v1.13.0
3448
hooks:
3549
- id: mypy
3650
additional_dependencies: [types-pytz, types-Deprecated, types-PyYAML, types-requests, types-tabulate, types-jsonschema, django-stubs, django-stubs-ext]

docker-app/manage.py

100644100755
File mode changed.

docker-app/qfieldcloud/authentication/admin.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,52 @@
11
from django.contrib import admin
22
from django.contrib.admin import register
3+
from django.db.models import Q, QuerySet
4+
from django.http import HttpRequest
5+
from django.utils import timezone
36

47
from .models import AuthToken
58

69

10+
class AuthTokenClientTypeFilter(admin.SimpleListFilter):
11+
# Human-readable title which will be displayed
12+
title = "Client type"
13+
14+
# Parameter for the filter that will be used in the URL query.
15+
parameter_name = "client_type"
16+
17+
def lookups(
18+
self, request: HttpRequest, model_admin: admin.ModelAdmin
19+
) -> list[tuple[str, str]]:
20+
"""
21+
Returns a list of tuples. The first element in each
22+
tuple is the coded value for the option that will
23+
appear in the URL query. The second element is the
24+
human-readable name for the option that will appear
25+
in the right sidebar.
26+
Here it is just the several available `AuthToken.ClientType`.
27+
"""
28+
return AuthToken.ClientType.choices
29+
30+
def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
31+
"""
32+
Returns the filtered queryset based on the value
33+
provided in the query string and retrievable via
34+
`self.value()`.
35+
"""
36+
value = self.value()
37+
38+
if value is None:
39+
return queryset
40+
41+
accepted_values = [ct[0] for ct in AuthToken.ClientType.choices]
42+
if value not in accepted_values:
43+
raise NotImplementedError(
44+
f"Unknown client type: {value} (was expecting: {','.join(accepted_values)})"
45+
)
46+
47+
return queryset.filter(Q(client_type=value))
48+
49+
750
@register(AuthToken)
851
class AuthTokenAdmin(admin.ModelAdmin):
952
list_display = ("user", "created_at", "expires_at", "last_used_at", "client_type")
@@ -15,6 +58,21 @@ class AuthTokenAdmin(admin.ModelAdmin):
1558
"client_type",
1659
"user_agent",
1760
)
18-
list_filter = ("created_at", "last_used_at", "expires_at")
61+
list_filter = (
62+
"created_at",
63+
"last_used_at",
64+
"expires_at",
65+
AuthTokenClientTypeFilter,
66+
)
67+
68+
actions = ("expire_selected_tokens",)
1969

2070
search_fields = ("user__username__iexact", "client_type", "key__startswith")
71+
72+
def expire_selected_tokens(self, request: HttpRequest, queryset: QuerySet) -> None:
73+
"""
74+
Sets a set of tokens to expired by updating the `expires_at` date to now.
75+
Expires only valid tokens.
76+
"""
77+
now = timezone.now()
78+
queryset.filter(Q(expires_at__gt=now)).update(expires_at=now)

docker-app/qfieldcloud/core/admin.py

Lines changed: 23 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,55 +1258,23 @@ def download_deltafile(self, request, queryset: QuerySet) -> HttpResponse | None
12581258
)
12591259

12601260

1261-
class GeodbAdmin(QFieldCloudModelAdmin):
1262-
list_filter = ("created_at", "hostname")
1263-
list_display = (
1264-
"user",
1265-
"username",
1266-
"dbname",
1267-
"hostname",
1268-
"port",
1269-
"created_at",
1270-
"size",
1271-
)
1272-
1273-
fields = (
1274-
"user",
1275-
"username",
1276-
"dbname",
1277-
"hostname",
1278-
"port",
1279-
"created_at",
1280-
"size",
1281-
"last_geodb_error",
1282-
)
1283-
1284-
readonly_fields = ("size", "created_at", "last_geodb_error")
1285-
1286-
search_fields = (
1287-
"user__username",
1288-
"username",
1289-
"dbname",
1290-
"hostname",
1291-
)
1292-
1293-
def save_model(self, request, obj, form, change):
1294-
# Only on creation
1295-
if not change:
1296-
messages.add_message(
1297-
request,
1298-
messages.WARNING,
1299-
f"The password is (shown only once): {obj.password}",
1300-
)
1301-
super().save_model(request, obj, form, change)
1302-
1303-
13041261
class OrganizationMemberInline(admin.TabularInline):
13051262
model = OrganizationMember
13061263
fk_name = "organization"
13071264
extra = 0
13081265

1309-
autocomplete_fields = ("member",)
1266+
# These fields must be autocomplete due to performance issue in the default Django admin theme, as the foreign key dropdown renders all the options.
1267+
autocomplete_fields = (
1268+
"member",
1269+
"created_by",
1270+
"updated_by",
1271+
)
1272+
readonly_fields = (
1273+
"created_by",
1274+
"created_at",
1275+
"updated_by",
1276+
"updated_at",
1277+
)
13101278

13111279

13121280
class TeamInline(admin.TabularInline):
@@ -1417,6 +1385,17 @@ def get_search_results(self, request, queryset, search_term):
14171385

14181386
return queryset, use_distinct
14191387

1388+
def save_formset(self, request, form, formset, change):
1389+
for form_obj in formset:
1390+
if isinstance(form_obj.instance, OrganizationMember):
1391+
# add created_by only if it's a newly created OrganizationMember
1392+
if form_obj.instance.id is None:
1393+
form_obj.instance.created_by = request.user
1394+
1395+
form_obj.instance.updated_by = request.user
1396+
1397+
super().save_formset(request, form, formset, change)
1398+
14201399

14211400
class TeamMemberInline(admin.TabularInline):
14221401
model = TeamMember
@@ -1522,7 +1501,6 @@ class LogEntryAdmin(
15221501
admin.site.register(Secret, SecretAdmin)
15231502
admin.site.register(Delta, DeltaAdmin)
15241503
admin.site.register(Job, JobAdmin)
1525-
admin.site.register(Geodb, GeodbAdmin)
15261504
admin.site.register(LogEntry, LogEntryAdmin)
15271505

15281506
# The sole purpose of the `User` and `UserAccount` admin modules is only to support autocomplete fields in Django admin

docker-app/qfieldcloud/core/cron.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,4 @@ def do(self):
128128
if package_id in job_ids:
129129
continue
130130

131-
storage.delete_stored_package(project_id, package_id)
131+
storage.delete_stored_package(project, package_id)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from django.db.models import QuerySet
2+
from django.http import HttpRequest
3+
from rest_framework import filters, viewsets
4+
5+
6+
class QfcOrderingFilter(filters.OrderingFilter):
7+
"""Custom QFC OrderingFilter class that allows usage of custom attributes expression.
8+
9+
Use it in a ModelViewSet by setting the `filter_backends` and `ordering_fields` fields.
10+
It is possible to use an `ordering_fields` expression value with attributes.
11+
Custom attributes expression has form : "my_field::alias=my_field_alias,key=value"
12+
"""
13+
14+
SEPARATOR = "::"
15+
TOKENS_LIST_SEPARATOR = ","
16+
TOKENS_VALUE_SEPARATOR = "="
17+
18+
def _get_query_field(self, fields: list[str], term: str) -> str | None:
19+
"""Searches a term in a query field list.
20+
21+
The field list elements may start with "-".
22+
This method should be used to search a term from a query's ordering fields.
23+
24+
Args:
25+
fields (list[str]): list of fields to search
26+
term (str): term to search in the list
27+
Returns:
28+
str | None: the matching field, if present in the list
29+
"""
30+
for field in fields:
31+
compare_value = field
32+
33+
# the "-" is used when the fields are sorted descending
34+
if field.startswith("-"):
35+
compare_value = field[1:]
36+
37+
if compare_value != term:
38+
continue
39+
40+
return field
41+
return None
42+
43+
def _parse_tokenized_attributes(self, raw: str) -> dict[str, str]:
44+
"""Parses an ordering field attributes expression.
45+
46+
Args:
47+
raw (str): raw expression to parse, e.g.: "alias=my_field_alias,key=value"
48+
Returns:
49+
dict[str, str]: dict containing the expression's attributes
50+
"""
51+
definition_attrs = raw.split(self.TOKENS_LIST_SEPARATOR)
52+
53+
attr_dict = {}
54+
for attr in definition_attrs:
55+
token, value = attr.split(self.TOKENS_VALUE_SEPARATOR, 1)
56+
attr_dict[token] = value
57+
58+
return attr_dict
59+
60+
def _parse_definition(self, definition: str) -> tuple[str, dict[str, str]]:
61+
"""Parses a custom ordering field with attributes expression.
62+
63+
Args:
64+
definition (str): raw definition of the ordering field to parse,
65+
e.g.: "my_field::alias=my_field_alias,key=value"
66+
Returns :
67+
tuple[str, dict[str, str]]: tuple containing the field name (1st) and its attributes dict (2nd)
68+
"""
69+
name, attr_str = definition.split(self.SEPARATOR, 1)
70+
attrs = self._parse_tokenized_attributes(attr_str)
71+
72+
return name, attrs
73+
74+
def remove_invalid_fields(
75+
self,
76+
queryset: QuerySet,
77+
fields: list[str],
78+
view: viewsets.ModelViewSet,
79+
request: HttpRequest,
80+
) -> list[str]:
81+
"""Process ordering fields by parsing custom field expression.
82+
83+
Custom attributes expression has form : "my_field::alias=my_field_alias,key=value".
84+
In the above example, `alias` is the URL GET param value,
85+
but `my_field` is the real model field.
86+
87+
Args:
88+
queryset (QuerySet): Django's ORM queryset of the same model as the one used in view of the `ModelViewSet`
89+
fields (list[str]): ordering fields passed to the HTTP querystring
90+
view (ModelViewSet): DRF view instance
91+
request (HttpRequest): DRF request instance
92+
Returns :
93+
list[str]: parsed ordering fields where aliases have been replaced
94+
"""
95+
base_fields = super().remove_invalid_fields(queryset, fields, view, request)
96+
valid_fields = []
97+
98+
for field_name, _verbose_name in self.get_valid_fields(
99+
queryset, view, context={"request": request}
100+
):
101+
# standard handling of fields from the base class
102+
query_field_name = self._get_query_field(base_fields, field_name)
103+
104+
if query_field_name:
105+
valid_fields.append(query_field_name)
106+
continue
107+
108+
# skip fields without custom attributes expression
109+
if self.SEPARATOR not in field_name:
110+
continue
111+
112+
definition_name, attrs = self._parse_definition(field_name)
113+
alias = attrs.get("alias", definition_name)
114+
query_field_name = self._get_query_field(fields, alias)
115+
116+
# field is not in the HTTP GET request querystring
117+
if not query_field_name:
118+
continue
119+
120+
if query_field_name.startswith("-"):
121+
definition_name = f"-{definition_name}"
122+
123+
valid_fields.append(definition_name)
124+
125+
return valid_fields

0 commit comments

Comments
 (0)