Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subprojects #104

Open
wants to merge 2 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion onyx/data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions onyx/data/models/projects/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from utils.constraints import (
unique_together,
optional_value_group,
ordering,
non_futures,
conditional_required,
)

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 12 additions & 12 deletions onyx/data/queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion onyx/data/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
)

Expand Down
5 changes: 3 additions & 2 deletions onyx/data/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
)
Expand Down
127 changes: 105 additions & 22 deletions onyx/utils/constraints.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,53 @@
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)}",
)


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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading