diff --git a/canopeum_backend/canopeum_backend/management/commands/initialize_database.py b/canopeum_backend/canopeum_backend/management/commands/initialize_database.py index 145b6ef7f..ef23b42eb 100644 --- a/canopeum_backend/canopeum_backend/management/commands/initialize_database.py +++ b/canopeum_backend/canopeum_backend/management/commands/initialize_database.py @@ -27,6 +27,7 @@ Mulchlayertype, Post, Role, + RoleName, Site, Siteadmin, Sitetreespecies, @@ -455,9 +456,8 @@ def create_assets(self): asset.asset.save(file_name, django_file, save=True) def create_roles(self): - Role.objects.create(name="User") - Role.objects.create(name="SiteManager") - Role.objects.create(name="MegaAdmin") + for role in RoleName: + Role.objects.create(name=role) def create_users(self): User.objects.create_user( @@ -466,37 +466,37 @@ def create_users(self): password="Adminbeslogic!", # noqa: S106 # MOCK_PASSWORD is_staff=True, is_superuser=True, - role=Role.objects.get(name="MegaAdmin"), + role=Role.objects.get(name=RoleName.MegaAdmin), ) User.objects.create_user( username="TyrionLannister", email="tyrion@lannister.com", password="tyrion123", # noqa: S106 # MOCK_PASSWORD - role=Role.objects.get(name="SiteManager"), + role=Role.objects.get(name=RoleName.ForestSteward), ) User.objects.create_user( username="DaenerysTargaryen", email="daenerys@targaryen.com", password="daenerys123", # noqa: S106 # MOCK_PASSWORD - role=Role.objects.get(name="SiteManager"), + role=Role.objects.get(name=RoleName.ForestSteward), ) User.objects.create_user( username="JonSnow", email="jon@snow.com", password="jon123", # noqa: S106 # MOCK_PASSWORD - role=Role.objects.get(name="SiteManager"), + role=Role.objects.get(name=RoleName.ForestSteward), ) User.objects.create_user( username="OberynMartell", email="oberyn@martell.com", password="oberyn123", # noqa: S106 # MOCK_PASSWORD - role=Role.objects.get(name="SiteManager"), + role=Role.objects.get(name=RoleName.ForestSteward), ) User.objects.create_user( username="NormalUser", email="normal@user.com", password="normal123", # noqa: S106 # MOCK_PASSWORD - role=Role.objects.get(name="User"), + role=Role.objects.get(name=RoleName.User), ) def create_sites(self): diff --git a/canopeum_backend/canopeum_backend/migrations/0002_alter_role_name.py b/canopeum_backend/canopeum_backend/migrations/0002_alter_role_name.py new file mode 100644 index 000000000..66c67235e --- /dev/null +++ b/canopeum_backend/canopeum_backend/migrations/0002_alter_role_name.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1 on 2024-10-29 21:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("canopeum_backend", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="role", + name="name", + field=models.CharField( + choices=[ + ("User", "User"), + ("ForestSteward", "Foreststeward"), + ("MegaAdmin", "Megaadmin"), + ], + max_length=13, + ), + ), + ] diff --git a/canopeum_backend/canopeum_backend/models.py b/canopeum_backend/canopeum_backend/models.py index 80ac06e0c..9f7851eb6 100644 --- a/canopeum_backend/canopeum_backend/models.py +++ b/canopeum_backend/canopeum_backend/models.py @@ -1,6 +1,7 @@ import re from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, override +from enum import auto +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast, override import googlemaps from django.contrib.auth.models import AbstractUser @@ -22,35 +23,38 @@ class RoleName(models.TextChoices): - USER = "User" - SITEMANAGER = "SiteManager" - MEGAADMIN = "MegaAdmin" + User = auto() + ForestSteward = auto() + MegaAdmin = auto() @classmethod def from_string(cls, value: str): try: return cls(value) except ValueError: - return cls.USER + return cls.User class Role(models.Model): - name = models.CharField( - max_length=11, - choices=RoleName.choices, - default=RoleName.USER, + # TODO: Request at https://github.com/typeddjango/django-stubs/issues + # for "choices" CharField to be understood as the enum type instead of a simple "str" + name = cast( + Literal["User", "ForestSteward", "MegaAdmin"], + models.CharField(max_length=max(len(val) for val in RoleName), choices=RoleName.choices), ) class User(AbstractUser): - email = models.EmailField( - verbose_name="email address", - max_length=255, - unique=True, - ) + email = models.EmailField(verbose_name="email address", max_length=255, unique=True) USERNAME_FIELD = "email" REQUIRED_FIELDS: ClassVar[list[str]] = [] - role = models.ForeignKey[Role, Role](Role, models.RESTRICT, null=False, default=1) + role = models.ForeignKey[Role, Role]( + Role, + models.RESTRICT, + null=False, + # Role.objects.get(name=RoleName.User).pk, but statically so we don't access DB during init + default=1, + ) if TYPE_CHECKING: # Missing "id" in "Model" or some base "User" class? id: int diff --git a/canopeum_backend/canopeum_backend/permissions.py b/canopeum_backend/canopeum_backend/permissions.py index ae834c7e5..f9807da7c 100644 --- a/canopeum_backend/canopeum_backend/permissions.py +++ b/canopeum_backend/canopeum_backend/permissions.py @@ -4,15 +4,14 @@ from rest_framework import permissions -from .models import Comment, Request, Site, Siteadmin, User +from .models import Comment, Request, RoleName, 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: Request, view, obj: Comment): - current_user_role = request.user.role.name - if current_user_role == "MegaAdmin": + if request.user.role.name == RoleName.MegaAdmin: return True is_admin_for_this_post = obj.post.site.siteadmin_set.filter( user__id__exact=request.user.id @@ -25,10 +24,10 @@ class PublicSiteReadPermission(permissions.BasePermission): 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" + isinstance(request.user, User) and request.user.role.name == RoleName.MegaAdmin ): return True - if not isinstance(request.user, User) or request.user.role.name != "SiteManager": + if not isinstance(request.user, User) or request.user.role.name != RoleName.ForestSteward: return False return ( @@ -40,32 +39,29 @@ class SiteAdminPermission(permissions.BasePermission): """Allows mega admins and a specific site's admin to perform site actions.""" def has_object_permission(self, request: Request, view, obj: Site) -> bool: - current_user_role = request.user.role.name - if current_user_role == "MegaAdmin": + if request.user.role.name == RoleName.MegaAdmin: return True return ( Siteadmin.objects.filter(user__id__exact=request.user.id).filter(site=obj.pk).exists() ) -class MegaAdminOrSiteManagerPermission(permissions.BasePermission): - """Global permission for actions only allowed to MegaAdmin or SiteManager users.""" +class MegaAdminOrForestStewardPermission(permissions.BasePermission): + """Global permission for actions only allowed to MegaAdmin or ForestSteward users.""" # About the type ignore: Base permission return type is Literal True but should be bool - def has_permission(self, request, view): - current_user_role = request.user.role.name # pyright: ignore[reportAttributeAccessIssue] - return current_user_role in {"MegaAdmin", "SiteManager"} + def has_permission(self, request: Request, view): + return request.user.role.name in {RoleName.MegaAdmin, RoleName.ForestSteward} class MegaAdminPermission(permissions.BasePermission): """Global permission for actions only allowed to MegaAdmin users.""" def has_permission(self, request: Request, view): - current_user_role = request.user.role.name - return current_user_role == "MegaAdmin" + return request.user.role.name == RoleName.MegaAdmin -READONLY_METHODS = ["GET", "HEAD", "OPTIONS"] +READONLY_METHODS = {"GET", "HEAD", "OPTIONS"} class MegaAdminPermissionReadOnly(permissions.BasePermission): @@ -77,8 +73,7 @@ class MegaAdminPermissionReadOnly(permissions.BasePermission): def has_permission(self, request: Request, view): if request.method in READONLY_METHODS: return True - current_user_role = request.user.role.name - return current_user_role == "MegaAdmin" + return request.user.role.name == RoleName.MegaAdmin class CurrentUserPermission(permissions.BasePermission): diff --git a/canopeum_backend/canopeum_backend/serializers.py b/canopeum_backend/canopeum_backend/serializers.py index 8178979da..7a98c2c9f 100644 --- a/canopeum_backend/canopeum_backend/serializers.py +++ b/canopeum_backend/canopeum_backend/serializers.py @@ -110,11 +110,11 @@ def create_user(self): if user_invitation.is_expired(): raise serializers.ValidationError("INVITATION_EXPIRED") from None - role = Role.objects.get(name="SiteManager") + role = Role.objects.get(name=RoleName.ForestSteward) except UserInvitation.DoesNotExist: raise serializers.ValidationError("INVITATION_CODE_INVALID") from None else: - role = Role.objects.get(name="User") + role = Role.objects.get(name=RoleName.User) user = User.objects.create( username=self.validated_data["username"], @@ -144,15 +144,13 @@ class Meta: exclude = ("password",) def get_role(self, obj: User) -> RoleName: - role_name = obj.role.name - return RoleName.from_string(role_name) # type: ignore[no-any-return] # mypy false-positive + return RoleName.from_string(obj.role.name) # type: ignore[no-any-return] # mypy false-positive def get_admin_site_ids(self, obj: User) -> list[int]: return [siteadmin.site.pk for siteadmin in Siteadmin.objects.filter(user=obj)] def get_followed_site_ids(self, obj: User) -> list[int]: - user_role = self.get_role(obj) - if user_role == RoleName.MEGAADMIN: + if obj.role.name == RoleName.MegaAdmin: return [site.pk for site in Site.objects.all()] return [site_follower.site.pk for site_follower in SiteFollower.objects.filter(user=obj)] diff --git a/canopeum_backend/canopeum_backend/urls.py b/canopeum_backend/canopeum_backend/urls.py index 4b370d319..2fa8e78f7 100644 --- a/canopeum_backend/canopeum_backend/urls.py +++ b/canopeum_backend/canopeum_backend/urls.py @@ -97,7 +97,11 @@ path("map/sites/", views.SiteMapListAPIView.as_view(), name="coordinate-list-sites"), # User path("users/", views.UserListAPIView.as_view(), name="user-list"), - path("users/site-managers", views.SiteManagersListAPIView.as_view(), name="site-managers-list"), + path( + "users/forest-stewards", + views.ForestStewardsListAPIView.as_view(), + name="forest-stewards-list", + ), path("users//", views.UserDetailAPIView.as_view(), name="user-detail"), path("users/current_user/", views.UserCurrentUserAPIView.as_view(), name="current-user"), path( diff --git a/canopeum_backend/canopeum_backend/views.py b/canopeum_backend/canopeum_backend/views.py index bf6102695..e4dd946ff 100644 --- a/canopeum_backend/canopeum_backend/views.py +++ b/canopeum_backend/canopeum_backend/views.py @@ -29,7 +29,7 @@ from canopeum_backend.permissions import ( CurrentUserPermission, DeleteCommentPermission, - MegaAdminOrSiteManagerPermission, + MegaAdminOrForestStewardPermission, MegaAdminPermission, MegaAdminPermissionReadOnly, PublicSiteReadPermission, @@ -102,9 +102,9 @@ def get_public_sites_unless_admin(user: User | None): - if isinstance(user, User) and user.role.name == "MegaAdmin": + if isinstance(user, User) and user.role.name == RoleName.MegaAdmin: sites = Site.objects.all() - elif isinstance(user, User) and user.role.name == "SiteManager": + elif isinstance(user, User) and user.role.name == RoleName.ForestSteward: admin_site_ids = [siteadmin.site.pk for siteadmin in Siteadmin.objects.filter(user=user)] sites = Site.objects.filter(Q(id__in=admin_site_ids) | Q(is_public=True)) else: @@ -113,9 +113,9 @@ def get_public_sites_unless_admin(user: User | None): def get_admin_sites(user: User): - if user.role.name == "MegaAdmin": + if user.role.name == RoleName.MegaAdmin: return Site.objects.all() - if isinstance(user, User) and user.role.name == "SiteManager": + if isinstance(user, User) and user.role.name == RoleName.ForestSteward: admin_site_ids = [siteadmin.site.pk for siteadmin in Siteadmin.objects.filter(user=user)] return Site.objects.filter(Q(id__in=admin_site_ids)) @@ -182,7 +182,7 @@ def post(self, request: Request): class TreeSpeciesAPIView(APIView): - permission_classes = (MegaAdminOrSiteManagerPermission,) + permission_classes = (MegaAdminOrForestStewardPermission,) @extend_schema(responses=TreeTypeSerializer(many=True), operation_id="tree_species") def get(self, request: Request): @@ -200,7 +200,7 @@ def get(self, request: Request): class FertilizerListAPIView(APIView): - permission_classes = (MegaAdminOrSiteManagerPermission,) + permission_classes = (MegaAdminOrForestStewardPermission,) @extend_schema( responses=FertilizerTypeSerializer(many=True), operation_id="fertilizer_allTypes" @@ -212,7 +212,7 @@ def get(self, request): class MulchLayerListAPIView(APIView): - permission_classes = (MegaAdminOrSiteManagerPermission,) + permission_classes = (MegaAdminOrForestStewardPermission,) @extend_schema( responses=MulchLayerTypeSerializer(many=True), operation_id="mulchLayer_allTypes" @@ -408,7 +408,7 @@ def patch(self, request: Request, siteId): class SiteSummaryListAPIView(APIView): - permission_classes = (MegaAdminOrSiteManagerPermission,) + permission_classes = (MegaAdminOrForestStewardPermission,) @extend_schema(responses=SiteSummarySerializer(many=True), operation_id="site_summary_all") def get(self, request: Request): @@ -472,7 +472,7 @@ def patch(self, request: Request, siteId): updated_admin_users_list = User.objects.filter(id__in=admin_ids) for user in updated_admin_users_list: - if user not in existing_admin_users and user.role.name == RoleName.SITEMANAGER: + if user not in existing_admin_users and user.role.name == RoleName.ForestSteward: Siteadmin.objects.create( user=user, site=site, @@ -536,10 +536,10 @@ class AdminUserSitesAPIView(APIView): operation_id="admin-user-sites_all", ) def get(self, request: Request): - site_manager_users = User.objects.filter(role__name__iexact=RoleName.SITEMANAGER).order_by( + forest_stewards = User.objects.filter(role__name__iexact=RoleName.ForestSteward).order_by( "username" ) - serializer = AdminUserSitesSerializer(site_manager_users, many=True) + serializer = AdminUserSitesSerializer(forest_stewards, many=True) return Response(serializer.data) @@ -1058,12 +1058,12 @@ def get(self, request: Request): return Response(serializer.data) -class SiteManagersListAPIView(APIView): +class ForestStewardsListAPIView(APIView): permission_classes = (MegaAdminPermission,) - @extend_schema(responses=UserSerializer(many=True), operation_id="user_allSiteManagers") + @extend_schema(responses=UserSerializer(many=True), operation_id="user_allForestStewards") def get(self, request: Request): - users = User.objects.filter(role__name__iexact=RoleName.SITEMANAGER).order_by("username") + users = User.objects.filter(role__name__iexact=RoleName.ForestSteward).order_by("username") serializer = UserSerializer(users, many=True) return Response(serializer.data) diff --git a/canopeum_frontend/src/components/Navbar.tsx b/canopeum_frontend/src/components/Navbar.tsx index 7613e39bb..70ba4cf93 100644 --- a/canopeum_frontend/src/components/Navbar.tsx +++ b/canopeum_frontend/src/components/Navbar.tsx @@ -11,7 +11,7 @@ type NavbarItem = { icon: MaterialIcon, linkTo: string, label: string, - roles: (RoleEnum | undefined)[], + roles: RoleEnum[], } const NAVBAR_ITEMS: NavbarItem[] = [ @@ -19,26 +19,26 @@ const NAVBAR_ITEMS: NavbarItem[] = [ icon: 'home', linkTo: appRoutes.home, label: 'home', - roles: ['User', 'SiteManager', 'MegaAdmin'], + roles: ['User', 'ForestSteward', 'MegaAdmin'], }, { icon: 'donut_small', linkTo: appRoutes.sites, label: 'sites', - roles: ['SiteManager', 'MegaAdmin'], + roles: ['ForestSteward', 'MegaAdmin'], }, { icon: 'pin_drop', linkTo: appRoutes.map, label: 'map', - roles: ['User', 'SiteManager', 'MegaAdmin'], + roles: ['User', 'ForestSteward', 'MegaAdmin'], }, // For development purposes // { // icon: 'style', // linkTo: appRoutes.utilities, // label: 'utilities', - // roles: ['SiteManager', 'MegaAdmin'], + // roles: ['ForestSteward', 'MegaAdmin'], // }, ] @@ -105,7 +105,7 @@ const Navbar = () => { ? 'active' : '' } ${ - item.roles.includes(currentUser?.role) + (currentUser && item.roles.includes(currentUser.role)) ? 'd-inline' : 'd-none' }`} diff --git a/canopeum_frontend/src/components/social/PostComment.tsx b/canopeum_frontend/src/components/social/PostComment.tsx index 89501ff5e..6112b9bb6 100644 --- a/canopeum_frontend/src/components/social/PostComment.tsx +++ b/canopeum_frontend/src/components/social/PostComment.tsx @@ -17,7 +17,7 @@ const PostComment = ({ comment, onDelete, siteId }: Props) => { const canDeleteComment = currentUser && ( currentUser.role === 'MegaAdmin' - || (currentUser.role === 'SiteManager' && currentUser.adminSiteIds.includes(siteId)) + || (currentUser.role === 'ForestSteward' && currentUser.adminSiteIds.includes(siteId)) || comment.authorId === currentUser.id ) diff --git a/canopeum_frontend/src/pages/Analytics.tsx b/canopeum_frontend/src/pages/Analytics.tsx index 3933a1e89..9415d3472 100644 --- a/canopeum_frontend/src/pages/Analytics.tsx +++ b/canopeum_frontend/src/pages/Analytics.tsx @@ -33,7 +33,7 @@ const Analytics = () => { ) const fetchAdmins = useCallback( - async () => setAdminList(await getApiClient().userClient.allSiteManagers()), + async () => setAdminList(await getApiClient().userClient.allForestStewards()), [getApiClient], ) diff --git a/canopeum_frontend/src/services/api.ts b/canopeum_frontend/src/services/api.ts index c959d8962..a1326f44d 100644 --- a/canopeum_frontend/src/services/api.ts +++ b/canopeum_frontend/src/services/api.ts @@ -2984,8 +2984,8 @@ export class UserClient { return Promise.resolve(null as any) } - allSiteManagers(): Promise { - let url_ = this.baseUrl + '/users/site-managers' + allForestStewards(): Promise { + let url_ = this.baseUrl + '/users/forest-stewards' url_ = url_.replace(/[?&]$/, '') let options_: RequestInit = { @@ -2996,11 +2996,11 @@ export class UserClient { } return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processAllSiteManagers(_response) + return this.processAllForestStewards(_response) }) } - protected processAllSiteManagers(response: Response): Promise { + protected processAllForestStewards(response: Response): Promise { const status = response.status let _headers: any = {} if (response.headers && response.headers.forEach) { @@ -4915,7 +4915,7 @@ export interface IRegisterUser { [key: string]: any } -export type RoleEnum = 'User' | 'SiteManager' | 'MegaAdmin' +export type RoleEnum = 'User' | 'ForestSteward' | 'MegaAdmin' export class Site implements ISite { readonly id!: number