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

Iron out type-checking and formatting #138

Merged
merged 14 commits into from
May 16, 2024
Merged
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 .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# You can run this locally with `pre-commit run [--all]`
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ For backend
py -3.12 -m venv .venv
```

Then activate the environemnt (you need to do this everytime if your editor isn't configured to do so):
Then activate the environment (you need to do this everytime if your editor isn't configured to do so):

```shell
source .venv/scripts/activate
Expand All @@ -83,8 +83,8 @@ For backend
5. Set up Django backend and Database: (Skip this section for Frontend only)

```shell
docker compose up
cd canopeum_backend
docker compose up
python -m pip install -r requirements-dev.txt
python manage.py initialize_database
python manage.py runserver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from django.db import ProgrammingError, connection
from django.utils import timezone

from canopeum_backend import settings
import canopeum_backend.settings
from canopeum_backend.models import (
Announcement,
Asset,
Expand Down Expand Up @@ -145,8 +145,8 @@ def create_posts_for_site(site):
# Create a post for the site
post = Post.objects.create(
site=site,
body=f"""{site.name} has planted {random.randint(100, 1000)} new trees today.
Let's continue to grow our forest!""",
body=f"{site.name} has planted {random.randint(100, 1000)} new trees today. "
+ "Let's continue to grow our forest!",
share_count=share_count,
)
# Change created_at date since it is auto-generated on create
Expand Down Expand Up @@ -266,7 +266,7 @@ def handle(self, *args, **kwargs):
try:
for asset in assets_to_delete:
path = (
Path(settings.BASE_DIR)
Path(canopeum_backend.settings.BASE_DIR)
/ "canopeum_backend"
/ "media"
/ asset.asset.name
Expand Down Expand Up @@ -349,7 +349,9 @@ def create_site_types(self):
)

def create_assets(self):
seeding_images_path = Path(settings.BASE_DIR) / "canopeum_backend" / "seeding" / "images"
seeding_images_path = (
Path(canopeum_backend.settings.BASE_DIR) / "canopeum_backend" / "seeding" / "images"
)
image_file_names = (
"site_img1.png",
"site_img2.jpg",
Expand All @@ -374,39 +376,39 @@ def create_users(self):
User.objects.create_user(
username="admin",
email="[email protected]",
password="Adminbeslogic!", # noqa: S106 MOCK_PASSWORD
password="Adminbeslogic!", # noqa: S106 # MOCK_PASSWORD
is_staff=True,
is_superuser=True,
role=Role.objects.get(name="MegaAdmin"),
)
User.objects.create_user(
username="TyrionLannister",
email="[email protected]",
password="tyrion123", # noqa: S106 MOCK_PASSWORD
password="tyrion123", # noqa: S106 # MOCK_PASSWORD
role=Role.objects.get(name="SiteManager"),
)
User.objects.create_user(
username="DaenerysTargaryen",
email="[email protected]",
password="daenerys123", # noqa: S106 MOCK_PASSWORD
password="daenerys123", # noqa: S106 # MOCK_PASSWORD
role=Role.objects.get(name="SiteManager"),
)
User.objects.create_user(
username="JonSnow",
email="[email protected]",
password="jon123", # noqa: S106 MOCK_PASSWORD
password="jon123", # noqa: S106 # MOCK_PASSWORD
role=Role.objects.get(name="SiteManager"),
)
User.objects.create_user(
username="OberynMartell",
email="[email protected]",
password="oberyn123", # noqa: S106 MOCK_PASSWORD
password="oberyn123", # noqa: S106 # MOCK_PASSWORD
role=Role.objects.get(name="SiteManager"),
)
User.objects.create_user(
username="NormalUser",
email="[email protected]",
password="normal123", # noqa: S106 MOCK_PASSWORD
password="normal123", # noqa: S106 # MOCK_PASSWORD
role=Role.objects.get(name="User"),
)

Expand Down Expand Up @@ -437,7 +439,7 @@ def create_canopeum_site(self):
image=Asset.objects.first(),
announcement=Announcement.objects.create(
body="We currently have 20000 healthy seedlings of different species, "
+ "ready to be planted at any time!"
+ "ready to be planted at any time! "
+ "Please click the link below to book your favorite seedlings on our website",
link="https://www.canopeum-pos.com",
),
Expand Down Expand Up @@ -490,11 +492,9 @@ def create_other_sites(self):
),
image=Asset.objects.get(asset__contains="site_img2"),
announcement=Announcement.objects.create(
body="""
Maple Grove Retreat is excited to announce our upcoming Maple Syrup Festival!
Join us on March 15th for a day of maple syrup tastings, nature hikes,
and family fun. Learn more on our website.
""",
body="Maple Grove Retreat is excited to announce our upcoming Maple Syrup "
+ "Festival! Join us on March 15th for a day of maple syrup tastings, "
+ "nature hikes, and family fun. Learn more on our website.",
link="https://www.maplegroveretreat.com/events/maple-syrup-festival",
),
)
Expand All @@ -514,8 +514,8 @@ def create_other_sites(self):
dd_longitude=-71.3075,
address="456 Lakeview Road, Lac-Saint-Jean, QC G8M 1R9",
),
description="""Lakeside Oasis offers a tranquil retreat by the shores of Lac-Saint-Jean,
with pristine waters and breathtaking sunsets.""",
description="Lakeside Oasis offers a tranquil retreat by the shores of "
+ "Lac-Saint-Jean, with pristine waters and breathtaking sunsets.",
size="800",
research_partnership=False,
visible_map=True,
Expand Down Expand Up @@ -563,10 +563,10 @@ def create_other_sites(self):
),
image=Asset.objects.get(asset__contains="site_img4"),
announcement=Announcement.objects.create(
body="""Discover the wonders of Evergreen Trail!
Our guided nature walks are now available every weekend.
Immerse yourself in nature and learn about the diverse
flora and fauna of Mont-Tremblant.""",
body="Discover the wonders of Evergreen Trail! "
+ "Our guided nature walks are now available every weekend. "
+ "Immerse yourself in nature and learn about the diverse "
+ "flora and fauna of Mont-Tremblant.",
link="https://www.evergreentrail.com/guided-walks",
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-05-15 18:52

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("canopeum_backend", "0001_initial"),
]

operations = [
migrations.AlterField(
model_name="batch",
name="size",
field=models.IntegerField(blank=True, null=True),
),
]
51 changes: 35 additions & 16 deletions canopeum_backend/canopeum_backend/models.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
from datetime import datetime, timedelta
from typing import ClassVar
from typing import TYPE_CHECKING, ClassVar

import pytz
from django.contrib.auth.models import AbstractUser
from django.db import models
from rest_framework.request import Request as drf_Request

# Pyright won't be able to infer all types here, see:
# https://github.com/typeddjango/django-stubs/issues/579
# https://github.com/typeddjango/django-stubs/issues/1264
# For now we have to rely on the mypy plugin


class RoleName(models.TextChoices):
USER = "User"
SITEMANAGER = "SiteManager"
MEGAADMIN = "MegaAdmin"

def from_string(self, value):
if value == self.MEGAADMIN:
return self.MEGAADMIN
if value == self.SITEMANAGER:
return self.SITEMANAGER
return self.USER
@classmethod
def from_string(cls, value: str):
Comment on lines +20 to +21
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CC @NicolasDontigny This is how you do a classmethod in Python (ie: a method you call on the class itself, this is technically different than a static method, but this would be a static method in other languages)

try:
return cls(value)
except ValueError:
return cls.USER


class Role(models.Model):
Expand All @@ -34,8 +40,11 @@ class User(AbstractUser):
unique=True,
)
USERNAME_FIELD = "email"
REQUIRED_FIELDS: ClassVar[list[str]] = [] # type: ignore
role = models.ForeignKey(Role, models.RESTRICT, null=False, default=1) # type: ignore
REQUIRED_FIELDS: ClassVar[list[str]] = []
role = models.ForeignKey[Role, Role](Role, models.RESTRICT, null=False, default=1)
if TYPE_CHECKING:
# Missing "id" in "Model" or some base "User" class?
id: int


class Announcement(models.Model):
Expand All @@ -49,7 +58,7 @@ class Batch(models.Model):
updated_at = models.DateTimeField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
sponsor = models.TextField(blank=True, null=True)
size = models.TextField(blank=True, null=True)
size = models.IntegerField(blank=True, null=True)
soil_condition = models.TextField(blank=True, null=True)
total_number_seed = models.IntegerField(blank=True, null=True)
total_propagation = models.IntegerField(blank=True, null=True)
Expand Down Expand Up @@ -157,18 +166,21 @@ class Site(models.Model):
image = models.ForeignKey(Asset, models.DO_NOTHING, blank=True, null=True)


# Note: PostAsset must be defined before Post because of a limitation with ManyToManyField type
# inference using string annotations: https://github.com/typeddjango/django-stubs/issues/1802
# Can't manually annotate because of: https://github.com/typeddjango/django-stubs/issues/760
class PostAsset(models.Model):
post = models.ForeignKey("Post", models.DO_NOTHING, null=False)
asset = models.ForeignKey(Asset, models.DO_NOTHING, null=False)


class Post(models.Model):
site = models.ForeignKey("Site", models.DO_NOTHING, blank=False, null=False)
body = models.TextField(blank=False, null=False)
share_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True, blank=False, null=False)
# created_by = models.ForeignKey(User, models.DO_NOTHING, blank=True, null=True)
media = models.ManyToManyField(Asset, through="PostAsset", blank=True)


class PostAsset(models.Model):
post = models.ForeignKey(Post, models.DO_NOTHING, null=False)
asset = models.ForeignKey(Asset, models.DO_NOTHING, null=False)
media = models.ManyToManyField(Asset, through=PostAsset, blank=True)


class Comment(models.Model):
Expand Down Expand Up @@ -234,3 +246,10 @@ class Like(models.Model):
class Internationalization(models.Model):
en = models.TextField(db_column="EN", blank=True, null=True)
fr = models.TextField(db_column="FR", blank=True, null=True)


class Request(drf_Request):
"""A custom Request type to use for parameter annotations."""

# Override with our own User model
user: User # pyright: ignore[reportIncompatibleMethodOverride]
23 changes: 12 additions & 11 deletions canopeum_backend/canopeum_backend/permissions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# More precise request param
# pyright: reportIncompatibleMethodOverride=false
# mypy: disable_error_code=override

from rest_framework import permissions

from .models import Comment, Site, Siteadmin, User
from .models import Comment, Request, Site, Siteadmin, User


class DeleteCommentPermission(permissions.BasePermission):
"""Deleting a comment is only allowed for admins or the comment's author."""

def has_object_permission(self, request, view, obj: Comment):
def has_object_permission(self, request: Request, view, obj: Comment):
current_user_role = request.user.role.name
if current_user_role == "MegaAdmin":
return True
Expand All @@ -19,8 +23,7 @@ def has_object_permission(self, request, view, obj: Comment):
class PublicSiteReadPermission(permissions.BasePermission):
"""Site methods only allowed if they are public, or the user is a site admin."""

# About the type ignore: Base permission return type is Literal True but should be bool
def has_object_permission(self, request, view, obj: Site) -> bool: # type: ignore
def has_object_permission(self, request: Request, view, obj: Site) -> bool:
if obj.is_public or (
isinstance(request.user, User) and request.user.role.name == "MegaAdmin"
):
Expand All @@ -36,8 +39,7 @@ def has_object_permission(self, request, view, obj: Site) -> bool: # type: igno
class SiteAdminPermission(permissions.BasePermission):
"""Allows mega admins and a specific site's admin to perform site actions."""

# About the type ignore: Base permission return type is Literal True but should be bool
def has_object_permission(self, request, view, obj: Site) -> bool: # type: ignore
def has_object_permission(self, request: Request, view, obj: Site) -> bool:
current_user_role = request.user.role.name
if current_user_role == "MegaAdmin":
return True
Expand All @@ -49,16 +51,15 @@ def has_object_permission(self, request, view, obj: Site) -> bool: # type: igno
class MegaAdminOrSiteManagerPermission(permissions.BasePermission):
"""Global permission for actions only allowed to MegaAdmin or SiteManager users."""

# About the type ignore: Base permission return type is Literal True but should be bool
def has_permission(self, request, view): # type: ignore
def has_permission(self, request: Request, view):
current_user_role = request.user.role.name
return current_user_role in {"MegaAdmin", "SiteManager"}


class MegaAdminPermission(permissions.BasePermission):
"""Global permission for actions only allowed to MegaAdmin users."""

def has_permission(self, request, view):
def has_permission(self, request: Request, view):
current_user_role = request.user.role.name
return current_user_role == "MegaAdmin"

Expand All @@ -72,7 +73,7 @@ class MegaAdminPermissionReadOnly(permissions.BasePermission):
This one will allow GET requests for any user, though.
"""

def has_permission(self, request, view):
def has_permission(self, request: Request, view):
if request.method in READONLY_METHODS:
return True
current_user_role = request.user.role.name
Expand All @@ -82,5 +83,5 @@ def has_permission(self, request, view):
class CurrentUserPermission(permissions.BasePermission):
"""Permission specific to a user, only allowed for this authenticated user."""

def has_object_permission(self, request, view, obj):
def has_object_permission(self, request: Request, view, obj):
return obj == request.user
Loading
Loading