Skip to content

Commit

Permalink
Merge pull request #829 from ae-utbm/taiste
Browse files Browse the repository at this point in the history
Family tree and blazingly fast SAS
  • Loading branch information
imperosol authored Sep 18, 2024
2 parents ae16a1b + 7458f62 commit ec434be
Show file tree
Hide file tree
Showing 53 changed files with 2,671 additions and 1,183 deletions.
4 changes: 2 additions & 2 deletions .github/actions/setup_project/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ runs:
- name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: gettext libgraphviz-dev
packages: gettext
version: 1.0 # increment to reset cache

- name: Install dependencies
run: |
sudo apt update
sudo apt install gettext libgraphviz-dev
sudo apt install gettext
shell: bash

- name: Set up python
Expand Down
12 changes: 12 additions & 0 deletions accounting/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ def to_python(self, value):
return None


if settings.TESTING:
from model_bakery import baker

baker.generators.add(
CurrencyField,
lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2),
)
else: # pragma: no cover
# baker is only used in tests, so we don't need coverage for this part
pass


# Accounting classes


Expand Down
72 changes: 70 additions & 2 deletions core/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
from typing import Annotated

import annotated_types
from django.conf import settings
from django.http import HttpResponse
from ninja_extra import ControllerBase, api_controller, route
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema

from club.models import Mailing
from core.schemas import MarkdownSchema
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot
from core.models import User
from core.schemas import (
FamilyGodfatherSchema,
MarkdownSchema,
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
)
from core.templatetags.renderer import markdown


Expand All @@ -27,3 +41,57 @@ def fetch_mailing_lists(self, key: str):
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data


@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter])
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)

@route.get("/search", response=PaginatedResponseSchema[UserProfileSchema])
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(User.objects.all())


DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4


@api_controller("/family")
class FamilyController(ControllerBase):
@route.get(
"/{user_id}",
permissions=[CanView],
response=UserFamilySchema,
url_name="family_graph",
)
def get_family_graph(
self,
user_id: int,
godfathers_depth: DepthValue = DEFAULT_DEPTH,
godchildren_depth: DepthValue = DEFAULT_DEPTH,
):
user: User = self.get_object_or_exception(User, pk=user_id)

relations = user.get_family(godfathers_depth, godchildren_depth)
if not relations:
# If the user has no relations, return only the user
# He is alone in its family, but the family exists nonetheless
return {"users": [user], "relationships": []}

user_ids = {r.from_user_id for r in relations} | {
r.to_user_id for r in relations
}
return {
"users": User.objects.filter(id__in=user_ids).distinct(),
"relationships": (
[
FamilyGodfatherSchema(
godchild=r.from_user_id, godfather=r.to_user_id
)
for r in relations
]
),
}
20 changes: 17 additions & 3 deletions core/api_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ def bar_delete(self, bar_id: int):
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission

from counter.models import Counter


class IsInGroup(BasePermission):
"""Check that the user is in the group whose primary key is given."""
Expand Down Expand Up @@ -78,7 +80,7 @@ class CanView(BasePermission):
"""Check that this user has the permission to view the object of this route.
Wrap the `user.can_view(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""

def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
Expand All @@ -94,7 +96,7 @@ class CanEdit(BasePermission):
"""Check that this user has the permission to edit the object of this route.
Wrap the `user.can_edit(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""

def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
Expand All @@ -110,7 +112,7 @@ class IsOwner(BasePermission):
"""Check that this user owns the object of this route.
Wrap the `user.is_owner(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""

def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
Expand All @@ -120,3 +122,15 @@ def has_object_permission(
self, request: HttpRequest, controller: ControllerBase, obj: Any
) -> bool:
return request.user.is_owner(obj)


class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""

def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META["HTTP_REFERER"]:
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()
125 changes: 125 additions & 0 deletions core/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from pathlib import Path

from django.core.exceptions import FieldError
from django.db import models
from django.db.models.fields.files import ImageFieldFile
from PIL import Image

from core.utils import resize_image_explicit


class ResizedImageFieldFile(ImageFieldFile):
def get_resized_dimensions(self, image: Image.Image) -> tuple[int, int]:
"""Get the dimensions of the resized image.
If the width and height are given, they are used.
If only one is given, the other is calculated to keep the same ratio.
Returns:
Tuple of width and height
"""
width = self.field.width
height = self.field.height
if width is not None and height is not None:
return self.field.width, self.field.height
if width is None:
width = int(image.width * height / image.height)
elif height is None:
height = int(image.height * width / image.width)
return width, height

def get_name(self) -> str:
"""Get the name of the resized image.
If the field has a force_format attribute,
the extension of the file will be changed to match it.
Otherwise, the name is left unchanged.
Raises:
ValueError: If the image format is unknown
"""
if not self.field.force_format:
return self.name
formats = {val: key for key, val in Image.registered_extensions().items()}
new_format = self.field.force_format
if new_format in formats:
extension = formats[new_format]
else:
raise ValueError(f"Unknown format {new_format}")
return str(Path(self.file.name).with_suffix(extension))

def save(self, name, content, save=True): # noqa FBT002
content.file.seek(0)
img = Image.open(content.file)
width, height = self.get_resized_dimensions(img)
img_format = self.field.force_format or img.format
new_content = resize_image_explicit(img, (width, height), img_format)
name = self.get_name()
return super().save(name, new_content, save)


class ResizedImageField(models.ImageField):
"""A field that automatically resizes images to a given size.
This field is useful for profile pictures or product icons, for example.
The final size of the image is determined by the width and height parameters :
- If both are given, the image will be resized
to fit in a rectangle of width x height
- If only one is given, the other will be calculated to keep the same ratio
If the force_format parameter is given, the image will be converted to this format.
Examples:
To resize an image with a height of 100px, without changing the ratio,
and a format of WEBP :
```python
class Product(models.Model):
icon = ResizedImageField(height=100, force_format="WEBP")
```
To explicitly resize an image to 100x100px (but possibly change the ratio) :
```python
class Product(models.Model):
icon = ResizedImageField(width=100, height=100)
```
Raises:
FieldError: If neither width nor height is given
Args:
width: If given, the width of the resized image
height: If given, the height of the resized image
force_format: If given, the image will be converted to this format
"""

attr_class = ResizedImageFieldFile

def __init__(
self,
width: int | None = None,
height: int | None = None,
force_format: str | None = None,
**kwargs,
):
if width is None and height is None:
raise FieldError(
f"{self.__class__.__name__} requires "
"width, height or both, but got neither"
)
self.width = width
self.height = height
self.force_format = force_format
super().__init__(**kwargs)

def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.width is not None:
kwargs["width"] = self.width
if self.height is not None:
kwargs["height"] = self.height
kwargs["force_format"] = self.force_format
return name, path, args, kwargs
3 changes: 1 addition & 2 deletions core/lookups.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ class CustomerLookup(RightManagedLookupChannel):
model = Customer

def get_query(self, q, request):
users = search_user(q)
return [user.customer for user in users]
return list(Customer.objects.filter(user__in=search_user(q)))

def format_match(self, obj):
return obj.user.get_mini_item()
Expand Down
2 changes: 1 addition & 1 deletion core/management/commands/populate_more.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ def create_sales(self, sellers: list[User]):
since=Subquery(
Subscription.objects.filter(member__customer=OuterRef("pk"))
.annotate(res=Min("subscription_start"))
.values("res")
.values("res")[:1]
)
)
)
Expand Down
40 changes: 38 additions & 2 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from pydantic.v1 import NonNegativeInt

if TYPE_CHECKING:
from club.models import Club
Expand Down Expand Up @@ -606,6 +607,41 @@ def get_age(self):
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
)

def get_family(
self,
godfathers_depth: NonNegativeInt = 4,
godchildren_depth: NonNegativeInt = 4,
) -> set[User.godfathers.through]:
"""Get the family of the user, with the given depth.
Args:
godfathers_depth: The number of generations of godfathers to fetch
godchildren_depth: The number of generations of godchildren to fetch
Returns:
A list of family relationships in this user's family
"""
res = []
for depth, key, reverse_key in [
(godfathers_depth, "from_user_id", "to_user_id"),
(godchildren_depth, "to_user_id", "from_user_id"),
]:
if depth == 0:
continue
links = list(User.godfathers.through.objects.filter(**{key: self.id}))
res.extend(links)
for _ in range(1, depth):
ids = [getattr(c, reverse_key) for c in links]
links = list(
User.godfathers.through.objects.filter(
**{f"{key}__in": ids}
).exclude(id__in=[r.id for r in res])
)
if not links:
break
res.extend(links)
return set(res)

def email_user(self, subject, message, from_email=None, **kwargs):
"""Sends an email to this User."""
if from_email is None:
Expand Down Expand Up @@ -955,8 +991,8 @@ def is_owned_by(self, user):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
if self.is_in_sas:
return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
return user.id == self.owner_id

def can_be_viewed_by(self, user):
Expand Down
Loading

0 comments on commit ec434be

Please sign in to comment.