diff --git a/onyx/data/fields.py b/onyx/data/fields.py index f6b821e..337e503 100644 --- a/onyx/data/fields.py +++ b/onyx/data/fields.py @@ -146,7 +146,7 @@ def __init__( self.code = project.code model = project.content_type.model_class() assert model is not None - assert issubclass(model, ProjectRecord) + # assert issubclass(model, ProjectRecord) self.model = model self.app_label = project.content_type.app_label self.action = action diff --git a/onyx/data/models/projects/test.py b/onyx/data/models/projects/test.py index f3e2129..4c0844c 100644 --- a/onyx/data/models/projects/test.py +++ b/onyx/data/models/projects/test.py @@ -4,6 +4,8 @@ from utils.constraints import ( unique_together, optional_value_group, + ordering, + non_futures, conditional_required, ) @@ -58,6 +60,18 @@ class Meta: model_name="basetestmodel", fields=["text_option_1", "text_option_2"], ), + ordering( + model_name="basetestmodel", + fields=("collection_month", "received_month"), + ), + ordering( + model_name="basetestmodel", + fields=("start", "end"), + ), + non_futures( + model_name="basetestmodel", + fields=["collection_month", "received_month", "submission_date"], + ), conditional_required( model_name="basetestmodel", field="region", @@ -98,6 +112,10 @@ class Meta: model_name="testmodelrecord", fields=["score_a", "score_b"], ), + ordering( + model_name="testmodelrecord", + fields=("test_start", "test_end"), + ), conditional_required( model_name="testmodelrecord", field="score_c", diff --git a/onyx/data/queryset.py b/onyx/data/queryset.py index 7324bcd..ae55de3 100644 --- a/onyx/data/queryset.py +++ b/onyx/data/queryset.py @@ -9,18 +9,18 @@ def init_project_queryset( ) -> models.manager.BaseManager[models.Model]: qs = model.objects.select_related() - if not user.is_staff: - # If the user is not a member of staff: - # - Ignore suppressed data - # - Ignore site_restricted objects from other sites - # TODO: For site_restricted to work properly, need to have site stored directly on project record - qs = qs.filter(suppressed=False).exclude( - models.Q(site_restricted=True) & ~models.Q(user__site=user.site) - ) - elif fields and "suppressed" not in fields: - # If the user is a member of staff, but the suppressed field is not in scope: - # - Ignore suppressed data - qs = qs.filter(suppressed=False) + # if not user.is_staff: + # # If the user is not a member of staff: + # # - Ignore suppressed data + # # - Ignore site_restricted objects from other sites + # # TODO: For site_restricted to work properly, need to have site stored directly on project record + # qs = qs.filter(suppressed=False).exclude( + # models.Q(site_restricted=True) & ~models.Q(user__site=user.site) + # ) + # elif fields and "suppressed" not in fields: + # # If the user is a member of staff, but the suppressed field is not in scope: + # # - Ignore suppressed data + # qs = qs.filter(suppressed=False) return qs diff --git a/onyx/data/serializers/__init__.py b/onyx/data/serializers/__init__.py index c39d159..d81584c 100644 --- a/onyx/data/serializers/__init__.py +++ b/onyx/data/serializers/__init__.py @@ -3,6 +3,7 @@ from django.db.models import Model from . import projects from .serializers import ProjectRecordSerializer, SerializerNode, SummarySerializer +from .serializers import BaseRecordSerializer class ProjectSerializerMap: @@ -37,7 +38,7 @@ def get(cls, model: type[Model]) -> type[ProjectRecordSerializer]: # - The member has a Meta.model attribute predicate = ( lambda x: inspect.isclass(x) - and issubclass(x, ProjectRecordSerializer) + and issubclass(x, BaseRecordSerializer) and hasattr(x.Meta, "model") ) diff --git a/onyx/data/views.py b/onyx/data/views.py index 076d826..808225c 100644 --- a/onyx/data/views.py +++ b/onyx/data/views.py @@ -51,7 +51,7 @@ def initial(self, request: Request, *args, **kwargs): # Get the project's model model = self.project.content_type.model_class() assert model is not None - assert issubclass(model, ProjectRecord) + # assert issubclass(model, ProjectRecord) self.model = model # Get the model's serializer @@ -170,7 +170,8 @@ def get(self, request: Request, code: str) -> Response: { "name": self.project.name, "description": self.project.description, - "version": self.model.version(), + # "version": self.model.version(), + "version": "0.1.0", "fields": fields_spec, } ) diff --git a/onyx/utils/constraints.py b/onyx/utils/constraints.py index 0e1842f..2efd2c8 100644 --- a/onyx/utils/constraints.py +++ b/onyx/utils/constraints.py @@ -1,14 +1,26 @@ import functools import operator -from datetime import datetime from django.db import models -from django.db.models import Q +from django.db.models import F, Q # TODO: Test constraints def unique_together(model_name: str, fields: list[str]): + """ + Creates a unique constraint over the provided `fields`. + + This means that the combination of these fields in a given instance must be unique across all other instances. + + Args: + model_name: The name of the model (used in naming the constraint). + fields: The fields to create the constraint over. + + Returns: + The constraint. + """ + return models.UniqueConstraint( fields=fields, name=f"unique_together_{model_name}_{'_'.join(fields)}", @@ -16,10 +28,26 @@ def unique_together(model_name: str, fields: list[str]): def optional_value_group(model_name: str, fields: list[str]): + """ + Creates a constraint that ensures at least one of the provided `fields` is not null. + + Args: + model_name: The name of the model (used in naming the constraint). + fields: The fields to create the constraint over. + + Returns: + The constraint. + """ + + # For each field, build a Q object that requires the field is not null + q_objects = [Q(**{f"{field}__isnull": False}) for field in fields] + + # Reduce the Q objects into a single Q object that requires at least one of the fields is not null + # This is done by OR-ing the Q objects together + check = functools.reduce(operator.or_, q_objects) + return models.CheckConstraint( - check=functools.reduce( - operator.or_, [Q(**{f"{field}__isnull": False}) for field in fields] - ), + check=check, name=f"optional_value_group_{model_name}_{'_'.join(fields)}", violation_error_message="At least one of '" + "', '".join(fields) @@ -28,28 +56,63 @@ def optional_value_group(model_name: str, fields: list[str]): def ordering(model_name: str, fields: tuple[str, str]): + """ + Creates a constraint that ensures the first field is less than or equal to the second field. + + Args: + model_name: The name of the model (used in naming the constraint). + fields: The fields to create the constraint over. + + Returns: + The constraint. + """ + + # Split the fields tuple into lower and higher lower, higher = fields + + # Build a Q object that requires that either: + # - One of the two fields is null + # - The lower field is less than or equal to the higher field + check = ( + models.Q(**{f"{lower}__isnull": True}) + | models.Q(**{f"{higher}__isnull": True}) + | models.Q(**{f"{lower}__lte": models.F(higher)}) + ) + return models.CheckConstraint( - check=( - models.Q(**{f"{lower}__isnull": True}) - | models.Q(**{f"{higher}__isnull": True}) - | models.Q(**{f"{lower}__lte": models.F(higher)}) - ), + check=check, name=f"ordering_{model_name}_{lower}_{higher}", violation_error_message=f"The '{lower}' must be less than or equal to '{higher}'.", ) def non_futures(model_name: str, fields: list[str]): + """ + Creates a constraint that ensures that the provided `fields` are not from the future. + + Args: + model_name: The name of the model (used in naming the constraint). + fields: The fields to create the constraint over. + + Returns: + The constraint. + """ + + # For each field, build a Q object that requires the field is null or less than or equal to the last_modified field + q_objects = [ + Q(**{f"{field}__isnull": True}) | Q(**{f"{field}__lte": F("last_modified")}) + for field in fields + ] + + # Reduce the Q objects into a single Q object that requires all of the fields are not from the future + # This is done by AND-ing the Q objects together + check = functools.reduce( + operator.and_, + q_objects, + ) + return models.CheckConstraint( - check=functools.reduce( - operator.and_, - [ - Q(**{f"{field}__isnull": True}) - | ~Q(**{f"{field}__gt": datetime.now().date()}) - for field in fields - ], - ), + check=check, name=f"non_future_{model_name}_{'_'.join(fields)}", violation_error_message="At least one of '" + "', '".join(fields) @@ -58,11 +121,31 @@ def non_futures(model_name: str, fields: list[str]): def conditional_required(model_name: str, field: str, required: list[str]): + """ + Creates a constraint that ensures that the provided `field` must be null unless all of the `required` fields are not null. + + Args: + model_name: The name of the model (used in naming the constraint). + field: The field to create the constraint over. + required: The fields that are required in order to set the `field`. + + Returns: + The constraint. + """ + + # For each required field, build a Q object that requires the field is not null + q_objects = [Q(**{f"{req}__isnull": False}) for req in required] + + # Reduce the Q objects into a single Q object that requires all of the required fields are not null + requirements = functools.reduce(operator.and_, q_objects) + + # Build a Q object that requires that either: + # - The field is null + # - All of the required fields are not null + check = Q(**{f"{field}__isnull": True}) | requirements + return models.CheckConstraint( - check=Q(**{f"{field}__isnull": True}) - | functools.reduce( - operator.and_, [Q(**{f"{req}__isnull": False}) for req in required] - ), + check=check, name=f"conditional_required_{model_name}_{field}_requires_{'_'.join(required)}", violation_error_message="All of '" + "', '".join(required)