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 Jul 12, 2023
2 parents 59791f4 + 5c9ceaf commit 4ac69c1
Show file tree
Hide file tree
Showing 26 changed files with 525 additions and 120 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ QField and QFieldCloud documentation is deployed [here](https://docs.qfield.org)

Clone the repository and all its submodules:

git clone --recurse-submodules git://github.com/opengisch/qfieldcloud.git
git clone --recurse-submodules git@github.com:opengisch/qfieldcloud.git

To fetch upstream development, don't forget to update the submodules too:

Expand Down
59 changes: 43 additions & 16 deletions docker-app/qfieldcloud/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,15 @@ def admin_urlname_by_obj(value, arg):
"type",
"email",
"date_joined",
"last_login",
"verified",
"owner_id",
"owner_username",
"owner_email",
"owner_first_name",
"owner_last_name",
"owner_date_joined",
"owner_last_login",
],
)

Expand All @@ -149,12 +152,12 @@ class EmailAddressAdmin(EmailAddressAdminBase):
def get_urls(self):
urls = super().get_urls()
return [
*urls,
path(
"admin/export_emails_to_csv/",
self.admin_site.admin_view(self.export_emails_to_csv),
name="export_emails_to_csv",
),
*urls,
]

def gen_users_email_addresses(self) -> Generator[UserEmailDetails, None, None]:
Expand All @@ -168,6 +171,7 @@ def gen_users_email_addresses(self) -> Generator[UserEmailDetails, None, None]:
u.first_name,
u.last_name,
u.type,
u.last_login,
u.date_joined
FROM
core_user u
Expand All @@ -186,13 +190,16 @@ def gen_users_email_addresses(self) -> Generator[UserEmailDetails, None, None]:
u.first_name,
u.last_name,
u.date_joined,
u.last_login,
u.type,
ae.verified,
oo.id AS "owner_id",
oo.username AS "owner_username",
oo.email AS "owner_email",
oo.first_name AS "owner_first_name",
oo.last_name AS "owner_last_name",
oo.date_joined AS "owner_date_joined"
oo.date_joined AS "owner_date_joined",
oo.last_login AS "owner_last_login"
FROM
u
LEFT JOIN account_emailaddress ae ON ae.user_id = u.id
Expand All @@ -210,12 +217,15 @@ def gen_users_email_addresses(self) -> Generator[UserEmailDetails, None, None]:
row.type,
row.email,
row.date_joined,
row.last_login,
row.verified,
row.owner_id,
row.owner_username,
row.owner_email,
row.owner_first_name,
row.owner_last_name,
row.owner_date_joined,
row.owner_last_login,
)
for row in raw_queryset
)
Expand Down Expand Up @@ -459,10 +469,19 @@ class PersonAdmin(QFieldCloudModelAdmin):

@admin.display(description=_("Storage"))
def storage_usage__field(self, instance) -> str:
active_storage_total = filesizeformat10(
instance.useraccount.current_subscription.active_storage_total_bytes
)
used_storage = filesizeformat10(instance.useraccount.storage_used_bytes)
free_storage = filesizeformat10(instance.useraccount.storage_free_bytes)
used_storage_perc = instance.useraccount.storage_used_ratio * 100
return f"{used_storage} {free_storage} ({used_storage_perc:.2f}%)"
free_storage = filesizeformat10(instance.useraccount.storage_free_bytes)

return _("total: {}; used: {} ({:.2f}%); free: {}").format(
active_storage_total,
used_storage,
used_storage_perc,
free_storage,
)

def save_model(self, request, obj, form, change):
# Set the password to the value in the field if it's changed.
Expand Down Expand Up @@ -1051,14 +1070,13 @@ class OrganizationAdmin(QFieldCloudModelAdmin):
"email",
"organization_owner",
"date_joined",
"active_users_links",
)
list_display = (
"username",
"email",
"organization_owner__link",
"date_joined",
# "storage_usage__field",
"active_users",
)

search_fields = (
Expand All @@ -1068,22 +1086,31 @@ class OrganizationAdmin(QFieldCloudModelAdmin):
"organization_owner__email__iexact",
)

readonly_fields = ("date_joined", "storage_usage__field", "active_users")
readonly_fields = (
"date_joined",
"storage_usage__field",
"active_users_links",
)

list_select_related = ("organization_owner",)
list_select_related = ("organization_owner", "useraccount")

list_filter = ("date_joined",)

autocomplete_fields = ("organization_owner",)

@admin.display(description=_("Active users (last billing period)"))
def active_users(self, instance) -> int | None:
# The relation 'current_subscription_vw' is not instantiated unless the organization
# does have a current subscription
if hasattr(instance, "current_subscription_vw"):
return instance.current_subscription_vw.active_users_count
else:
return None
@admin.display(description=_("Active members"))
def active_users_links(self, instance) -> str:
persons = instance.useraccount.current_subscription.active_users
userlinks = "<p> - </p>"
if persons:
userlinks = "<br>".join(model_admin_url(p, p.username) for p in persons)
help_text = """
<p style="font-size: 11px; color: var(--body-quiet-color)">
Active members have triggererd at least one job or uploaded at least one delta in the current billing period.
These are all the users who will be billed -- plan included or additional.
</p>
"""
return format_html(f"{userlinks} {help_text}")

@admin.display(description=_("Owner"))
def organization_owner__link(self, instance):
Expand Down
71 changes: 70 additions & 1 deletion docker-app/qfieldcloud/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,74 @@ def needs_repackaging(self) -> bool:
# if the project has online vector layers (PostGIS/WFS/etc) we cannot be sure if there are modification or not, so better say there are
return True

@property
def problems(self) -> list[dict]:
problems = []

if not self.project_filename:
problems.append(
{
"layer": None,
"level": "error",
"code": "missing_projectfile",
"description": _("Missing QGIS project file (.qgs/.qgz)."),
"solution": _(
"Make sure a QGIS project file (.qgs/.qgz) is uploaded to QFieldCloud. Reupload the file if problem persists."
),
}
)
elif self.project_details:
for layer_data in self.project_details.get("layers_by_id", {}).values():
layer_name = layer_data.get("name")

if layer_data.get("error_code") != "no_error":
problems.append(
{
"layer": layer_name,
"level": "warning",
"code": "layer_problem",
"description": _(
'Layer "{}" has an error with code "{}": {}'
).format(
layer_name,
layer_data.get("error_code"),
layer_data.get("error_summary"),
),
"solution": _(
'Check the last "process_projectfile" logs for more info and reupload the project files with the required changes.'
),
}
)
# the layer is missing a primary key, warn it is going to be read-only
elif layer_data.get("qfc_source_data_pk_name") == "":
problems.append(
{
"layer": layer_name,
"level": "warning",
"code": "layer_problem",
"description": _(
'Layer "{}" does not have supported primary key attribute. The layer will be read-only on QField.'
).format(
layer_name,
),
"solution": _(
"To make the layer editable on QField, store the layer data in a GeoPackage or PostGIS layer with single column primary key."
),
}
)
else:
problems.append(
{
"layer": None,
"level": "error",
"code": "missing_project_details",
"description": _("Failed to parse metadata from project."),
"solution": _("Re-upload the QGIS project file (.qgs/.qgz)."),
}
)

return problems

@property
def status(self) -> Status:
# NOTE the status is NOT stored in the db, because it might be outdated
Expand All @@ -1194,7 +1262,8 @@ def status(self) -> Status:
self.owner.useraccount.current_subscription.plan.max_premium_collaborators_per_private_project
)

if not self.project_filename:
# TODO use self.problems to get if there are project problems
if not self.project_filename or not self.project_details:
status = Project.Status.FAILED
status_code = Project.StatusCode.FAILED_PROCESS_PROJECTFILE
elif (
Expand Down
48 changes: 48 additions & 0 deletions docker-app/qfieldcloud/core/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from typing import Any, Callable

from django.conf import settings
from rest_framework import pagination, response


def parameterize_pagination(_class: type) -> Callable:
"""
Set as class attributes the items passed as kwargs.
"""

def configure_class_object(*args, **kwargs) -> type:
for k, v in kwargs.items():
setattr(_class, k, v)
return _class

return configure_class_object


@parameterize_pagination
class QfcLimitOffsetPagination(pagination.LimitOffsetPagination):
"""
Based on LimitOffsetPagination.
Custom implementation such that `response.data = LimitOffsetPagination.data.results` from DRF's blanket implementation.
Optionally sets a new header `X-Total-Count` to the number of entries in the paginated response.
Use it only if you can afford the performance cost.
Can be customized when assigning `pagination_class`.
"""

count_entries = True
default_limit = settings.QFIELDCLOUD_API_DEFAULT_PAGE_LIMIT

def get_headers(self) -> dict[str, Any]:
"""
Initializes a new header field to carry the number of paginated entries
if the class method `count_entries` is `True`.
"""
if self.count_entries:
return {"X-Total-Count": self.count}
else:
return {}

def get_paginated_response(self, data) -> response.Response:
"""
Sets the header field initialized in the previous method to the number of paginated entries.
Return just the entries in the response body.
"""
return response.Response(data, headers=self.get_headers())
79 changes: 79 additions & 0 deletions docker-app/qfieldcloud/core/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
import time

from django.core.cache import cache
from qfieldcloud.authentication.models import AuthToken
from qfieldcloud.core import pagination
from qfieldcloud.core.models import Person, Project
from qfieldcloud.core.views.projects_views import ProjectViewSet
from rest_framework import status
from rest_framework.test import APITransactionTestCase

from .utils import setup_subscription_plans

logging.disable(logging.CRITICAL)


Expand All @@ -13,6 +19,21 @@ def setUp(self):
# Empty cache value
cache.delete("status_results")

# Create needed subscription relations
setup_subscription_plans()

# Set up a user to own projects
self.user = Person.objects.create_user(username="user1", password="abc123")
self.token = AuthToken.objects.get_or_create(user=self.user)[0]

# Create a bunch of public projects
self.total_projects = 50
projects = (
Project(name=f"project{n}", is_public=True, owner=self.user)
for n in range(self.total_projects)
)
Project.objects.bulk_create(projects)

def test_api_status(self):
response = self.client.get("/api/v1/status/")
self.assertTrue(status.is_success(response.status_code))
Expand All @@ -26,3 +47,61 @@ def test_api_status_cache(self):
toc = time.perf_counter()

self.assertGreater(toc - tic, 0)

def test_api_pagination_limitoffset(self):
"""Test LimitOffset pagination custom implementation"""
# Authenticate client
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key)

page_size = 5
offset = 3
unlimited_count = Project.objects.all().count()
self.assertEqual(unlimited_count, self.total_projects)

# Obtain response with LIMIT
results_with_pagination = self.client.get(
"/api/v1/projects/", {"limit": page_size}
).json()
self.assertEqual(len(results_with_pagination), page_size)

# Obtain response with LIMIT and OFFSET
results_with_offset = self.client.get(
"/api/v1/projects/", {"limit": page_size, "offset": offset}
).json()

# Test page size
self.assertEqual(len(results_with_offset), page_size)

# Obtain without pagination (= control test)
results_without_offset_or_request_level_limit = self.client.get(
"/api/v1/projects/",
).json()

# Even though the request is not setting a limit, the Project modelviewset
# was defined with a default limit that's kicking in here
self.assertEqual(
ProjectViewSet.pagination_class.default_limit,
len(results_without_offset_or_request_level_limit),
)

def test_api_headers_count(self):
"""Test LimitOffset pagination custom 'X-Total-Count' headers implementation"""
# Authenticate client
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key)

# Forcing 'count_entries' to be True
ProjectViewSet.pagination_class = pagination.QfcLimitOffsetPagination(
count_entries=True
)
response = self.client.get("/api/v1/projects/")
self.assertEqual(
int(response.headers["X-Total-Count"]),
self.total_projects,
)

# Forcing 'count_entries' to be False
ProjectViewSet.pagination_class = pagination.QfcLimitOffsetPagination(
count_entries=False
)
response = self.client.get("/api/v1/projects/")
self.assertNotIn("X-Total-Count", response.headers)
Loading

0 comments on commit 4ac69c1

Please sign in to comment.