From 422bb4edf8d0cd425c86c978ba9663a95cda8659 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 13 Sep 2024 21:25:23 +0800
Subject: [PATCH 01/36] Add coverage testing library and add script to generate
coverage.xml
---
web/requirements.txt | 1 +
web/test-cov.sh | 5 +++++
web/test.sh | 4 +---
3 files changed, 7 insertions(+), 3 deletions(-)
create mode 100755 web/test-cov.sh
diff --git a/web/requirements.txt b/web/requirements.txt
index 02d07621e..a31191082 100644
--- a/web/requirements.txt
+++ b/web/requirements.txt
@@ -18,3 +18,4 @@ geojson==2.5.0
greenlet==1.1.3
pylint==2.13.9
pylint-django==2.5.3
+coverage==6.2
diff --git a/web/test-cov.sh b/web/test-cov.sh
new file mode 100755
index 000000000..1d39f0460
--- /dev/null
+++ b/web/test-cov.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+coverage run manage.py test
+coverage xml --omit=*/migrations*,*/management*
+sed -i 's/\/code/\./g' coverage.xml
\ No newline at end of file
diff --git a/web/test.sh b/web/test.sh
index 3a80edf12..8bbeb6239 100755
--- a/web/test.sh
+++ b/web/test.sh
@@ -1,6 +1,4 @@
#!/bin/bash
-./wait-for-it.sh db:5432
-
-docker-compose run web python3 manage.py test
+python manage.py test
From b456c0fa0a3df50b1ffea520a99fff529298bcaf Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 13 Sep 2024 21:26:13 +0800
Subject: [PATCH 02/36] Display report before generating xml file
---
web/test-cov.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/web/test-cov.sh b/web/test-cov.sh
index 1d39f0460..5b3ab559f 100755
--- a/web/test-cov.sh
+++ b/web/test-cov.sh
@@ -1,5 +1,6 @@
#!/bin/bash
coverage run manage.py test
+coverage report -m
coverage xml --omit=*/migrations*,*/management*
sed -i 's/\/code/\./g' coverage.xml
\ No newline at end of file
From d5bb6558a62eaae0802f05ce9818769c2013fe4b Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 13 Sep 2024 22:00:39 +0800
Subject: [PATCH 03/36] Make test-cov.sh to test select apps and move omit flag
---
web/test-cov.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/test-cov.sh b/web/test-cov.sh
index 5b3ab559f..21ee3579f 100755
--- a/web/test-cov.sh
+++ b/web/test-cov.sh
@@ -1,6 +1,6 @@
#!/bin/bash
-coverage run manage.py test
+coverage run --omit=*/migrations*,*/management*,*/tests* manage.py test "$@"
coverage report -m
-coverage xml --omit=*/migrations*,*/management*
+coverage xml
sed -i 's/\/code/\./g' coverage.xml
\ No newline at end of file
From c2c6500917f2465855ade9bae3e9906e61938aa1 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 16:17:00 +0800
Subject: [PATCH 04/36] Update script to run specified tests
---
web/test.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/test.sh b/web/test.sh
index 8bbeb6239..d7be4b854 100755
--- a/web/test.sh
+++ b/web/test.sh
@@ -1,4 +1,4 @@
#!/bin/bash
-python manage.py test
+python manage.py test "$@"
From 613021a6f308ea05290e67210d61eae5488f0484 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 16:17:15 +0800
Subject: [PATCH 05/36] Finish writing all grants tests
---
web/grants/tests.py | 93 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 93 insertions(+)
diff --git a/web/grants/tests.py b/web/grants/tests.py
index a39b155ac..120a622cf 100644
--- a/web/grants/tests.py
+++ b/web/grants/tests.py
@@ -1 +1,94 @@
# Create your tests here.
+from rest_framework.test import APITestCase
+from rest_framework import status
+
+from django.contrib.gis.geos import GEOSGeometry
+from django.core.cache import cache
+
+from grants.models import Grant, GrantCategory
+
+
+class BaseTestCase(APITestCase):
+ def setUp(self):
+ cache.clear()
+
+ FAKE_POINT = """{
+ "type": "Point",
+ "coordinates": [1, 1]
+ }"""
+
+ self.test_category = GrantCategory.objects.create(
+ name="Test Grant Categry", abbreviation="TGC"
+ )
+
+ self.test_grant = Grant.objects.create(
+ grant="Test Grant",
+ title="Test Grant Title",
+ year=2024,
+ point=GEOSGeometry(FAKE_POINT),
+ grant_category=self.test_category,
+ )
+
+ # This grant will not appear since we filter out grants without points
+ self.test_grant_no_point = Grant.objects.create(
+ grant="Test Grant No Point",
+ title="Test Grant No Point Title",
+ year=2024,
+ grant_category=self.test_category,
+ )
+
+
+class GrantAPIRouteTests(APITestCase):
+ def test_grant_list_route_exists(self):
+ """
+ Ensure Grant List API route exists
+ """
+ response = self.client.get("/api/grants/", format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_grant_category_route_exists(self):
+ """
+ Ensure Grant Categories List API route exists
+ """
+ response = self.client.get("/api/grant-categories/", format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+
+class GrantAPITests(BaseTestCase):
+ def test_grant_list(self):
+ """
+ Ensure Grant List API returns list of results with the correct data
+ """
+ response = self.client.get("/api/grants/", format="json")
+ data = response.data
+ print(data)
+ features = data.get("features")
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(features), 1)
+ self.assertEqual(
+ data["type"], "FeatureCollection"
+ ) # Confirm that this is a Geo API
+ self.assertEqual(features[0]["id"], self.test_grant.id)
+
+ def test_grant_detail(self):
+ """
+ Ensure Grant Detail API returns list of results with the correct data
+ """
+ response = self.client.get(f"/api/grants/{self.test_grant.id}/", format="json")
+ data = response.data
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(data["id"], self.test_grant.id)
+
+ def test_grant_category_list(self):
+ """
+ Ensure Grant Category List API returns list of results with the correct data
+ """
+ response = self.client.get("/api/grant-categories/", format="json")
+ data = response.data
+ print(data)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0]["id"], self.test_category.id)
From bce50df67419cdb839f748bb720683e04fb73583 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 16:32:16 +0800
Subject: [PATCH 06/36] Detect last name before returning anonymous
---
web/users/models.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/users/models.py b/web/users/models.py
index c259b2d6c..1be6495db 100755
--- a/web/users/models.py
+++ b/web/users/models.py
@@ -81,7 +81,7 @@ def is_profile_complete(self):
return bool(has_language and has_community)
def get_full_name(self):
- if self.first_name:
+ if self.first_name or self.last_name:
return "{} {}".format(self.first_name, self.last_name).strip()
return "Someone Anonymous"
From e9ac6ceacc7da1dc24f8be8eaa3009454496ea80 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 16:32:40 +0800
Subject: [PATCH 07/36] Removed redundant code
---
web/users/notifications.py | 15 ++-------------
1 file changed, 2 insertions(+), 13 deletions(-)
diff --git a/web/users/notifications.py b/web/users/notifications.py
index 3f24db189..f9851a295 100755
--- a/web/users/notifications.py
+++ b/web/users/notifications.py
@@ -8,18 +8,7 @@
from django.db.models import Q
from language.models import RelatedData
-
-
-def _format_fpcc(s):
-
- s = s.strip().lower()
- s = re.sub(
- r"\\|\/|>|<|\?|\)|\(|~|!|@|#|$|^|%|&|\*|=|\+|]|}|\[|{|\||;|:|_|\.|,|`|'|\"",
- "",
- s,
- )
- s = re.sub(r"\s+", "-", s)
- return s
+from web.utils import format_fpcc
# pylint: disable=line-too-long
@@ -67,7 +56,7 @@ def send_claim_profile_invite(email):
for data in email_data:
profile_links += """{host}/art/{profile}
""".format(
host=settings.HOST,
- profile=_format_fpcc(data.placename.name),
+ profile=format_fpcc(data.placename.name),
)
message = """
From f1700d5585c2d42d20a6e18a9dd2b0e16c85a961 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 16:34:18 +0800
Subject: [PATCH 08/36] Remove obsolete command
---
.../commands/test_claim_profile_invites.py | 13 --
web/users/notifications.py | 131 ------------------
2 files changed, 144 deletions(-)
delete mode 100644 web/users/management/commands/test_claim_profile_invites.py
delete mode 100755 web/users/notifications.py
diff --git a/web/users/management/commands/test_claim_profile_invites.py b/web/users/management/commands/test_claim_profile_invites.py
deleted file mode 100644
index e76f9782b..000000000
--- a/web/users/management/commands/test_claim_profile_invites.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from django.core.management.base import BaseCommand
-from users.notifications import send_claim_profile_invites
-
-
-class Command(BaseCommand):
- help = "Test sending out emails to registered users to claim their artist profiles."
-
- def handle(self, *args, **options):
- if options["email"]:
- send_claim_profile_invites((options["email"][0]))
-
- def add_arguments(self, parser):
- parser.add_argument("--email", nargs="+")
diff --git a/web/users/notifications.py b/web/users/notifications.py
deleted file mode 100755
index f9851a295..000000000
--- a/web/users/notifications.py
+++ /dev/null
@@ -1,131 +0,0 @@
-import os
-import re
-import hashlib
-import copy
-
-from django.conf import settings
-from django.core.mail import send_mail
-from django.db.models import Q
-
-from language.models import RelatedData
-from web.utils import format_fpcc
-
-
-# pylint: disable=line-too-long
-def send_claim_profile_invite(email):
- """
- Send claim profile invitation through email.
- """
-
- salt = os.environ["INVITE_SALT"].encode("utf-8")
- encoded_email = email.encode("utf-8")
- key = hashlib.sha256(salt + encoded_email).hexdigest()
-
- # email data refers to RelatedData with data_type = 'email'
- email_data = RelatedData.objects.exclude(
- (Q(value="") | Q(placename__kind__in=["resource", "grant"]))
- ).filter(
- (Q(data_type="email") | Q(data_type="user_email")),
- placename__creator__isnull=True,
- value=email,
- )
- email_data_copy = copy.deepcopy(email_data)
-
- # Exclude data if there is an actual_email. Used to give notif to
- # the actual email rather than the FPCC admin who seeded the profile
- for data in email_data:
- if data.data_type == "user_email":
- actual_email = RelatedData.objects.exclude(value="").filter(
- placename=data.placename, data_type="email"
- )
-
- if actual_email:
- email_data_copy = email_data_copy.exclude(id=data.id)
-
- email_data = email_data_copy
-
- # Check if the profile is already claimed. Otherwise, don't include it in the list of profiles to claim
- fully_claimed = True
- for data in email_data:
- if data.placename.creator is None:
- fully_claimed = False
- break
-
- if email_data and not fully_claimed:
- profile_links = ""
- for data in email_data:
- profile_links += """{host}/art/{profile}
""".format(
- host=settings.HOST,
- profile=format_fpcc(data.placename.name),
- )
-
- message = """
-
- Greetings from First People's Cultural Council!
- We recently sent you a message, telling you that the First Peoples’ Arts Map is being amalgamated into the First Peoples’ Map, which now includes Indigenous Language, Arts and Heritage in B.C. This update has provided an opportunity for us to make important changes and improvements to the map’s functions, features and design.
- We have now moved all of the data, including your profile(s) to the new website. All of the content you have published on the old Arts Map has been saved within the new First Peoples’ Map. To access your profile, do updates, and add new images, sound or video, you will need to register and claim your material. Listed below are the new links to your profile(s) for you to review:
- {profile_links}
- Please claim your profile(s) through the link below. Before you click on the link, here are a couple of helpful notes:
-
- - If you don't have an account registered on the new First Peoples’ Map, you will need to Sign-up after clicking on the link.
- - Please carefully enter your valid email address as the system will send you a verification code to confirm that email address.
- - In some cases, the email containing the verification code might end up as spam, so please thoroughly check your inbox.
-
- Claim Profile
- If you don't own the profile(s) listed above, or if you are in need of assistance, please contact us through {fpcc_email}. We are here to help!
- Miigwech, and have a good day!
- """.format(
- profile_links=profile_links,
- host=settings.HOST,
- fpcc_email="maps@fpcc.ca",
- email=email,
- key=key,
- )
-
- # Send out the message
- send_mail(
- subject="Claim Your FPCC Profile",
- message=message,
- from_email="First Peoples' Cultural Council ",
- recipient_list=[email],
- html_message=message,
- )
-
- print("Sent mail to: {}".format(email))
- else:
- print("User {} has no profiles to claim.".format(email))
-
-
-def send_claim_profile_invites(email=None):
- """
- Bulk Invite - Sends to every old artsmap users with profiles
- """
-
- if not email:
- emails = (
- RelatedData.objects.exclude(
- (Q(value="") | Q(placename__kind__in=["resource", "grant"]))
- )
- .filter(
- (Q(data_type="email") | Q(data_type="user_email")),
- placename__creator__isnull=True,
- )
- .distinct("value")
- .values_list("value", flat=True)
- )
- else:
- emails = (
- RelatedData.objects.exclude(
- (Q(value="") | Q(placename__kind__in=["resource", "grant"]))
- )
- .filter(
- (Q(data_type="email") | Q(data_type="user_email")),
- placename__creator__isnull=True,
- value=email,
- )
- .distinct("value")
- .values_list("value", flat=True)
- )
-
- for current_email in emails:
- send_claim_profile_invite(current_email)
From 688cf83745aae7e42d20c0d62144c0d1ef1314a6 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 17:15:00 +0800
Subject: [PATCH 09/36] Update access checks on some user APIs
---
web/users/views.py | 63 ++++++++++++++++++++++++++++------------------
1 file changed, 38 insertions(+), 25 deletions(-)
diff --git a/web/users/views.py b/web/users/views.py
index c491e4244..59fd8368b 100755
--- a/web/users/views.py
+++ b/web/users/views.py
@@ -53,9 +53,16 @@ class UserViewSet(UserCustomViewSet, GenericViewSet):
@method_decorator(never_cache)
def retrieve(self, request, *args, **kwargs):
if request and hasattr(request, "user"):
- if request.user.is_authenticated and request.user.id == int(
- kwargs.get("pk")
- ):
+ user_id = int(kwargs.get("pk"))
+
+ # Signed in but attempting to retrieve a different user
+ if request.user.is_authenticated and request.user.id != user_id:
+ return Response(
+ {"message": "You do not have access to this user's info."},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ if request.user.is_authenticated and request.user.id == user_id:
return super().retrieve(request)
return Response(
@@ -66,9 +73,16 @@ def retrieve(self, request, *args, **kwargs):
@method_decorator(never_cache)
def partial_update(self, request, *args, **kwargs):
if request and hasattr(request, "user"):
- if request.user.is_authenticated and request.user.id == int(
- kwargs.get("pk")
- ):
+ user_id = int(kwargs.get("pk"))
+
+ # Signed in but attempting to patch a different user
+ if request.user.is_authenticated and request.user.id != user_id:
+ return Response(
+ {"message": "You do not have access to update this user's info."},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ if request.user.is_authenticated and request.user.id == user_id:
return super().partial_update(request)
return Response(
@@ -80,6 +94,22 @@ def partial_update(self, request, *args, **kwargs):
def detail(self, request):
return super().detail(request)
+ @method_decorator(never_cache)
+ @action(detail=False)
+ def auth(self, request):
+ if not request.user.is_authenticated:
+ return Response({"is_authenticated": False})
+
+ return Response(
+ {
+ "is_authenticated": True,
+ "user": UserSerializer(request.user).data,
+ "administration_list": Administrator.objects.filter(
+ user=request.user
+ ).count(),
+ }
+ )
+
@method_decorator(never_cache)
@action(detail=False)
def login(self, request):
@@ -97,8 +127,7 @@ def login(self, request):
email=result["email"].strip(),
username=result["email"].replace("@", "__"),
password="",
- # not currently used, default to None
- picture=result.get("picture", None),
+ picture=result.get("picture", None), # unused, default to None
first_name=result["given_name"],
last_name=result["family_name"],
)
@@ -112,25 +141,9 @@ def login(self, request):
return Response({"success": False})
- @method_decorator(never_cache)
- @action(detail=False)
- def auth(self, request):
- if not request.user.is_authenticated:
- return Response({"is_authenticated": False})
-
- return Response(
- {
- "is_authenticated": True,
- "user": UserSerializer(request.user).data,
- "administration_list": Administrator.objects.filter(
- user=request.user
- ).count(),
- }
- )
-
@action(detail=False)
def logout(self, request):
- # TODO: invalidate the JWT on cognito ?
+ # TODO: invalidate the JWT on cognito
logout(request)
return Response({"success": True})
From 30d2316f94cd4ccbf7c5e37710cb61b079ac3846 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 17:15:20 +0800
Subject: [PATCH 10/36] Add missing tests for users app
---
web/users/tests.py | 104 ++++++++++++++++++++++++++++++++++-----------
1 file changed, 79 insertions(+), 25 deletions(-)
diff --git a/web/users/tests.py b/web/users/tests.py
index 8ff0fbf64..aa77609e8 100755
--- a/web/users/tests.py
+++ b/web/users/tests.py
@@ -12,17 +12,29 @@ def setUp(self):
self.community1 = Community.objects.create(name="Test community 001")
self.community2 = Community.objects.create(name="Test community 002")
- self.user = User.objects.create(
+ self.user1 = User.objects.create(
username="testuser001",
first_name="Test",
last_name="user 001",
- email="test@countable.ca",
+ email="test1@countable.ca",
)
- self.user.set_password("password")
- self.user.languages.add(self.language1)
- self.user.languages.add(self.language2)
- self.user.communities.add(self.community1)
- self.user.save()
+ self.user1.set_password("password")
+ self.user1.languages.add(self.language1)
+ self.user1.languages.add(self.language2)
+ self.user1.communities.add(self.community1)
+ self.user1.save()
+
+ self.user2 = User.objects.create(
+ username="testuser002",
+ first_name="Test",
+ last_name="user 002",
+ email="test2@countable.ca",
+ )
+ self.user2.set_password("password")
+ self.user2.languages.add(self.language1)
+ self.user2.languages.add(self.language2)
+ self.user2.communities.add(self.community1)
+ self.user2.save()
###### ONE TEST TESTS ONLY ONE SCENARIO ######
@@ -32,7 +44,8 @@ def test_user_detail_route_exists(self):
"""
self.client.login(username="testuser001", password="password")
response = self.client.get(
- "/api/user/{}".format(self.user.id), format="json", follow=True)
+ f"/api/user/{self.user1.id}", format="json", follow=True
+ )
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_user_detail(self):
@@ -41,12 +54,36 @@ def test_user_detail(self):
"""
self.client.login(username="testuser001", password="password")
response = self.client.get(
- "/api/user/{}/".format(self.user.id), format="json", follow=True)
+ f"/api/user/{self.user1.id}/", format="json", follow=True
+ )
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data["id"], self.user.id)
+ self.assertEqual(response.data["id"], self.user1.id)
self.assertEqual(len(response.data["languages"]), 2)
self.assertEqual(len(response.data["communities"]), 1)
+ response = self.client.get("/api/user/auth", format="json", follow=True)
+ self.assertEqual(response.data["is_authenticated"], True)
+ self.assertEqual(response.data["user"]["id"], self.user1.id)
+
+ def test_user_detail_unauthorized(self):
+ """
+ Test we can't fetch user details without signing in
+ """
+ response = self.client.get(
+ f"/api/user/{self.user2.id}/".format(), format="json", follow=True
+ )
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_user_detail_forbidden(self):
+ """
+ Test we can't fetch user details with the wrong user logged in
+ """
+ self.client.login(username="testuser001", password="password")
+ response = self.client.get(
+ f"/api/user/{self.user2.id}/".format(), format="json", follow=True
+ )
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
def test_user_post_not_allowed(self):
"""
Ensure there is no user create API
@@ -57,42 +94,59 @@ def test_user_post_not_allowed(self):
"username": "testuser001",
"first_name": "Test",
"last_name": "user 001",
- "email": "test@countable.ca",
+ "email": "test1@countable.ca",
},
format="json",
)
- self.assertEqual(response.status_code,
- status.HTTP_404_NOT_FOUND)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_user_set_community(self):
"""
- Check we can set the community
+ Test we can set the community
"""
- # TODO: test I can't edit without logging in.
self.client.login(username="testuser001", password="password")
response = self.client.patch(
- "/api/user/{}/".format(self.user.id),
+ f"/api/user/{self.user1.id}/",
{"community_ids": [self.community2.id, self.community1.id]},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# check updates are reflected in API.
- response = self.client.get(
- "/api/user/{}/".format(self.user.id), format="json")
+ response = self.client.get(f"/api/user/{self.user1.id}/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data["id"], self.user.id)
+ self.assertEqual(response.data["id"], self.user1.id)
self.assertEqual(len(response.data["languages"]), 2)
self.assertEqual(len(response.data["communities"]), 2)
def test_user_patch(self):
"""
- Check we can set the bio on the user's settings page.
+ Test we can set the bio on the user's settings page.
"""
self.client.login(username="testuser001", password="password")
- response = self.client.patch(
- "/api/user/{}/".format(self.user.id), {"bio": "bio"}
- )
+ response = self.client.patch(f"/api/user/{self.user1.id}/", {"bio": "bio"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
- response = self.client.get(
- "/api/user/{}/".format(self.user.id), format="json")
+ response = self.client.get(f"/api/user/{self.user1.id}/", format="json")
self.assertEqual(response.data["bio"], "bio")
+
+ def test_user_patch_unauthorized(self):
+ """
+ Test we can't patch a user without signing in
+ """
+ response = self.client.patch(f"/api/user/{self.user2.id}/", {"bio": "bio"})
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_user_patch_forbidden(self):
+ """
+ Test we can't patch a user with the wrong user logged in
+ """
+ self.client.login(username="testuser001", password="password")
+ response = self.client.patch(f"/api/user/{self.user2.id}/", {"bio": "bio"})
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+
+# Consider adding tests for login and logout in the future
+
+# The following do not have/need tests because they are obsolete, but we
+# don't want to remove them in case a very old user claims their profile:
+# - ConfirmClaimView
+# - ValidateInviteView
From 0613b255b91afb929e1b3cca245928799075cf0d Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Fri, 11 Oct 2024 17:18:27 +0800
Subject: [PATCH 11/36] Remove dead APIs
---
web/users/views.py | 28 ----------------------------
1 file changed, 28 deletions(-)
diff --git a/web/users/views.py b/web/users/views.py
index 59fd8368b..8f15acc51 100755
--- a/web/users/views.py
+++ b/web/users/views.py
@@ -90,10 +90,6 @@ def partial_update(self, request, *args, **kwargs):
status=status.HTTP_401_UNAUTHORIZED,
)
- @method_decorator(never_cache)
- def detail(self, request):
- return super().detail(request)
-
@method_decorator(never_cache)
@action(detail=False)
def auth(self, request):
@@ -147,30 +143,6 @@ def logout(self, request):
logout(request)
return Response({"success": True})
- @action(detail=False)
- def search(self, request):
- users_results = []
- params = request.GET.get("search_params")
-
- if params:
- qs = User.objects.filter(
- Q(first_name__icontains=params)
- | Q(last_name__icontains=params)
- | Q(email__icontains=params)
- )
-
- users_results = [
- {
- "id": user.id,
- "first_name": user.first_name,
- "last_name": user.last_name,
- "email": user.email,
- }
- for user in qs
- ]
-
- return Response(users_results)
-
class ConfirmClaimView(APIView):
def get(self, request):
From 8f6dc4e34e58eac80fb9ea845bdb5ae594eb9cf6 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 03:58:21 +0800
Subject: [PATCH 12/36] Added drf-yasg
---
web/requirements.txt | 2 ++
web/web/settings.py | 1 +
web/web/urls.py | 14 ++++++++++----
3 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/web/requirements.txt b/web/requirements.txt
index a31191082..9ee3a2530 100644
--- a/web/requirements.txt
+++ b/web/requirements.txt
@@ -19,3 +19,5 @@ greenlet==1.1.3
pylint==2.13.9
pylint-django==2.5.3
coverage==6.2
+drf-yasg==1.16.1
+packaging==21.3
\ No newline at end of file
diff --git a/web/web/settings.py b/web/web/settings.py
index 9f8710d60..3bf23a038 100644
--- a/web/web/settings.py
+++ b/web/web/settings.py
@@ -61,6 +61,7 @@
"rest_framework.authtoken",
"rest_framework_swagger",
"rest_framework_gis",
+ "drf_yasg",
"django_apscheduler",
"language",
"grants",
diff --git a/web/web/urls.py b/web/web/urls.py
index 753996ae4..2112cbd69 100755
--- a/web/web/urls.py
+++ b/web/web/urls.py
@@ -4,15 +4,21 @@
from django.contrib import admin
from django.contrib.sitemaps.views import sitemap
from django.urls import include, path
-from rest_framework import routers
+from rest_framework import routers, permissions
from rest_framework.authtoken.views import obtain_auth_token
-from rest_framework_swagger.views import get_swagger_view
+from drf_yasg.views import get_schema_view
+from drf_yasg import openapi
from web.sitemaps import LanguageSitemap, CommunitySitemap, PlaceNameSitemap
from web.views import PageViewSet
-schema_view = get_swagger_view(title="FPCC API")
+# schema_view = get_swagger_view(title="FPCC API")
+schema_view = get_schema_view(
+ openapi.Info(title="FPCC API", default_version='v1'),
+ public=True,
+ permission_classes=(permissions.IsAdminUser,),
+)
sitemaps = {
"language": LanguageSitemap(),
@@ -45,7 +51,7 @@ def crash(request):
path("api/", include("grants.urls"), name="grants"),
path("api/", include("users.urls"), name="users"),
url("docs/crash/$", crash),
- url("docs/$", schema_view),
+ url("docs/$", schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path(
"sitemap.xml",
sitemap,
From 86036faae2c143a74e33a249078a766a47157e20 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 05:49:08 +0800
Subject: [PATCH 13/36] Autoschema config
---
web/web/schema.py | 28 ++++++++++++++++++++++++++++
web/web/settings.py | 5 +++--
2 files changed, 31 insertions(+), 2 deletions(-)
create mode 100644 web/web/schema.py
diff --git a/web/web/schema.py b/web/web/schema.py
new file mode 100644
index 000000000..16171ce9b
--- /dev/null
+++ b/web/web/schema.py
@@ -0,0 +1,28 @@
+from drf_yasg.inspectors import SwaggerAutoSchema
+import inspect
+
+
+class CustomOpenAPISchema(SwaggerAutoSchema):
+ def get_summary_and_description(self):
+ """
+ Get description and summary from docstring if it's provided. Otherwise, use the default behavior.
+ """
+
+ docstring = inspect.getdoc(self.view)
+ if docstring:
+ description = docstring
+ summary = docstring.split("\n")[0]
+ else:
+ description = self.overrides.get("operation_description", None)
+ summary = self.overrides.get("operation_summary", None)
+ if description is None:
+ description = self._sch.get_description(self.path, self.method) or ""
+ description = description.strip().replace("\r", "")
+
+ if description and (summary is None):
+ # description from docstring... do summary magic
+ summary, description = self.split_summary_from_description(
+ description
+ )
+
+ return summary, description
diff --git a/web/web/settings.py b/web/web/settings.py
index 3bf23a038..a0405a6f7 100644
--- a/web/web/settings.py
+++ b/web/web/settings.py
@@ -59,7 +59,6 @@
"django_filters",
"rest_framework",
"rest_framework.authtoken",
- "rest_framework_swagger",
"rest_framework_gis",
"drf_yasg",
"django_apscheduler",
@@ -176,12 +175,14 @@
"rest_framework.renderers.BrowsableAPIRenderer",
)
+SWAGGER_SETTINGS = {
+ "DEFAULT_AUTO_SCHEMA_CLASS": "web.schema.CustomOpenAPISchema",
+}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
- "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
"DEFAULT_RENDERER_CLASSES": DEFAULT_RENDERER_CLASSES,
}
From d347d60195510cdd348b01f06bae2e9a84200099 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 05:53:01 +0800
Subject: [PATCH 14/36] Consistency updates
---
web/web/schema.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/web/web/schema.py b/web/web/schema.py
index 16171ce9b..50aab64ba 100644
--- a/web/web/schema.py
+++ b/web/web/schema.py
@@ -20,9 +20,12 @@ def get_summary_and_description(self):
description = description.strip().replace("\r", "")
if description and (summary is None):
- # description from docstring... do summary magic
summary, description = self.split_summary_from_description(
description
)
+ # If there's no description, set it to the summary for consistency
+ if summary and not description:
+ description = summary
+
return summary, description
From 1c318b7a79f21401821b0c0c0f70d5dc0f5b6132 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 06:33:24 +0800
Subject: [PATCH 15/36] Fix summary for actions
---
web/web/schema.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/web/schema.py b/web/web/schema.py
index 50aab64ba..2f45744f1 100644
--- a/web/web/schema.py
+++ b/web/web/schema.py
@@ -8,7 +8,7 @@ def get_summary_and_description(self):
Get description and summary from docstring if it's provided. Otherwise, use the default behavior.
"""
- docstring = inspect.getdoc(self.view)
+ docstring = self._sch.get_description(self.path, self.method) or ""
if docstring:
description = docstring
summary = docstring.split("\n")[0]
From 8bae5da8e26c16d6d7cd6406d62e1eb9061527e5 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 06:55:23 +0800
Subject: [PATCH 16/36] Roughly 50% of the backend API documentation
---
web/grants/views/grant.py | 6 +++
web/language/views/community.py | 51 ++++++++++++++++--
web/language/views/language.py | 20 +++++++
web/language/views/others.py | 66 ++++++++++++++++-------
web/language/views/placename.py | 95 +++++++++++++++++++++++++++++++--
web/users/views.py | 4 ++
6 files changed, 216 insertions(+), 26 deletions(-)
diff --git a/web/grants/views/grant.py b/web/grants/views/grant.py
index 9b25d47fe..a85508437 100644
--- a/web/grants/views/grant.py
+++ b/web/grants/views/grant.py
@@ -21,6 +21,9 @@ class GrantViewSet(
lookup_field = "id"
def list(self, request, *args, **kwargs):
+ """
+ List all Grants, in a geo format, to be used in the frontend's map.
+ """
return super().list(request)
@@ -36,4 +39,7 @@ class GrantCategoryViewSet(mixins.ListModelMixin, BaseGenericViewSet):
)
def list(self, request, *args, **kwargs):
+ """
+ List all grant categories.
+ """
return super().list(request)
diff --git a/web/language/views/community.py b/web/language/views/community.py
index 8f4698f02..dff251634 100755
--- a/web/language/views/community.py
+++ b/web/language/views/community.py
@@ -4,6 +4,7 @@
from rest_framework.response import Response
from rest_framework.decorators import action
from django_filters.rest_framework import DjangoFilterBackend
+from drf_yasg.utils import swagger_auto_schema
from users.models import User, Administrator
from language.models import (
@@ -48,6 +49,12 @@ def detail(self, request):
return super().detail(request)
def update(self, request, *args, **kwargs):
+ """
+ Update Community details (community admin access required).
+
+ This is only accessible to the community's admin configured through the Administrator model.
+ """
+
instance = self.get_object()
if is_user_community_admin(request, instance):
return super().update(request, *args, **kwargs)
@@ -58,6 +65,12 @@ def update(self, request, *args, **kwargs):
)
def destroy(self, request, *args, **kwargs):
+ """
+ Delete a Community object (community admin access required).
+
+ This is only accessible to the community's admin configured through the Administrator model.
+ """
+
instance = self.get_object()
if is_user_community_admin(request, instance):
return super().update(request, *args, **kwargs)
@@ -69,6 +82,12 @@ def destroy(self, request, *args, **kwargs):
@method_decorator(never_cache)
def retrieve(self, request, *args, **kwargs):
+ """
+ Retrieve a Community object (viewable information may vary).
+
+ Media/PlaceName configured as `community_only` will not be returned if the user is not a community member.
+ """
+
instance = self.get_object()
serializer = CommunityDetailSerializer(instance)
serialized_data = serializer.data
@@ -123,6 +142,10 @@ def retrieve(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def add_audio(self, request, pk):
+ """
+ Add a Recording to a Community object (community admin access required).
+ """
+
instance = self.get_object()
if is_user_community_admin(request, instance):
if "recording_id" not in request.data.keys():
@@ -151,6 +174,10 @@ def add_audio(self, request, pk):
@action(detail=True, methods=["post"])
def create_membership(self, request, pk):
+ """
+ Add a Community to a User object (deprecated).
+ """
+
instance = self.get_object()
if is_user_community_admin(request, instance):
if "user_id" not in request.data.keys():
@@ -182,6 +209,10 @@ def create_membership(self, request, pk):
@method_decorator(never_cache)
@action(detail=False)
def list_member_to_verify(self, request):
+ """
+ Lists all members that are awaiting verification.
+ """
+
# 'VERIFIED' or 'REJECTED' members do not need to the verified
members = CommunityMember.objects.exclude(status__exact=VERIFIED).exclude(
status__exact=REJECTED
@@ -201,6 +232,10 @@ def list_member_to_verify(self, request):
@action(detail=False, methods=["post"])
def verify_member(self, request):
+ """
+ Sets the status of a user's CommunityMembership to `VERIFIED`.
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
user_id = int(request.data["user_id"])
@@ -222,9 +257,7 @@ def verify_member(self, request):
return Response({"message": "Verified!"})
- return Response(
- {"message", "User is already a community member"}
- )
+ return Response({"message", "User is already a community member"})
return Response(
{"message", "Only Administrators can verify community members"}
@@ -236,6 +269,10 @@ def verify_member(self, request):
@action(detail=False, methods=["post"])
def reject_member(self, request):
+ """
+ Sets the status of a user's CommunityMembership to `REJECTED`.
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
if "user_id" not in request.data.keys():
@@ -302,6 +339,10 @@ class ChampionViewSet(BaseModelViewSet):
# Geo List APIViews
class CommunityGeoList(generics.ListAPIView):
+ """
+ List all Communities, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
Community.objects.filter(point__isnull=False)
.only("name", "other_names", "point")
@@ -321,6 +362,10 @@ def get_queryset(self):
# Search List APIViews
class CommunitySearchList(generics.ListAPIView):
+ """
+ List all Communities to be used in the frontend's search bar.
+ """
+
queryset = (
Community.objects.filter(point__isnull=False).only("name").order_by("name")
)
diff --git a/web/language/views/language.py b/web/language/views/language.py
index 24890327c..0bc1b0228 100755
--- a/web/language/views/language.py
+++ b/web/language/views/language.py
@@ -34,6 +34,10 @@ def detail(self, request):
@action(detail=True, methods=["patch"])
def add_language_audio(self, request, pk):
+ """
+ Add a `language_audio` (Recording) to a Language (Django admin access required).
+ """
+
# TODO - Instead of refetching language using PK, use self.get_object()
if "recording_id" not in request.data.keys():
return Response({"message": "No Recording was sent in the request"})
@@ -55,6 +59,10 @@ def add_language_audio(self, request, pk):
@action(detail=True, methods=["patch"])
def add_greeting_audio(self, request, pk):
+ """
+ Add a `greeting_audio` (Recording) to a Language (Django admin access required).
+ """
+
# TODO - Instead of refetching language using PK, use self.get_object()
if "recording_id" not in request.data.keys():
return Response({"message": "No Recording was sent in the request"})
@@ -75,6 +83,10 @@ def add_greeting_audio(self, request, pk):
)
def create_membership(self, request):
+ """
+ Add a Language to a User object (deprecated).
+ """
+
# TODO - Instead of refetching language using PK, use self.get_object()
language_id = int(request.data["language"]["id"])
language = Language.objects.get(pk=language_id)
@@ -87,6 +99,10 @@ def create_membership(self, request):
# Geo List APIViews
class LanguageGeoList(generics.ListAPIView):
+ """
+ List all Languages, in a geo format, to be used in the frontend's map.
+ """
+
filter_backends = [DjangoFilterBackend]
filterset_fields = ["name"]
@@ -98,6 +114,10 @@ class LanguageGeoList(generics.ListAPIView):
# Search List APIViews
class LanguageSearchList(generics.ListAPIView):
+ """
+ List all Languages to be used in the frontend's search bar.
+ """
+
queryset = Language.objects.filter(geom__isnull=False).only(
"name", "other_names", "family"
)
diff --git a/web/language/views/others.py b/web/language/views/others.py
index 523c46a2d..794058123 100755
--- a/web/language/views/others.py
+++ b/web/language/views/others.py
@@ -7,7 +7,11 @@
from language.models import Favourite, Notification, Recording
from language.views import BaseModelViewSet
-from language.serializers import FavouriteSerializer, NotificationSerializer, RecordingSerializer
+from language.serializers import (
+ FavouriteSerializer,
+ NotificationSerializer,
+ RecordingSerializer,
+)
from web.permissions import is_user_permitted
@@ -27,14 +31,16 @@ def perform_create(self, serializer):
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
- if request and hasattr(request, 'user'):
+ if request and hasattr(request, "user"):
if request.user.is_authenticated:
queryset = queryset.filter(user__id=request.user.id)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
- return Response({'message': 'Only logged in users can view theirs favourites'},
- status=status.HTTP_401_UNAUTHORIZED)
+ return Response(
+ {"message": "Only logged in users can view theirs favourites"},
+ status=status.HTTP_401_UNAUTHORIZED,
+ )
# To enable only CREATE and DELETE, we create a custom ViewSet class...
@@ -57,57 +63,79 @@ def perform_create(self, serializer):
@method_decorator(login_required)
def create(self, request, *args, **kwargs):
- if 'place' in request.data.keys() or 'media' in request.data.keys():
+ """
+ Sets a PlaceName or a Media as a favourite (login required).
+ """
+
+ if "place" in request.data.keys() or "media" in request.data.keys():
- if 'media' in request.data.keys():
- media_id = int(request.data['media'])
+ if "media" in request.data.keys():
+ media_id = int(request.data["media"])
# Check if the favourite already exists
if Favourite.favourite_media_already_exists(request.user.id, media_id):
- return Response({'message': 'This media is already a favourite'},
- status=status.HTTP_409_CONFLICT)
+ return Response(
+ {"message": "This media is already a favourite"},
+ status=status.HTTP_409_CONFLICT,
+ )
return super(FavouriteViewSet, self).create(request, *args, **kwargs)
- if 'place' in request.data.keys():
- placename_id = int(request.data['place'])
+ if "place" in request.data.keys():
+ placename_id = int(request.data["place"])
# Check if the favourite already exists
- if Favourite.favourite_place_already_exists(request.user.id, placename_id):
- return Response({'message': 'This placename is already a favourite'},
- status=status.HTTP_409_CONFLICT)
+ if Favourite.favourite_place_already_exists(
+ request.user.id, placename_id
+ ):
+ return Response(
+ {"message": "This placename is already a favourite"},
+ status=status.HTTP_409_CONFLICT,
+ )
return super(FavouriteViewSet, self).create(request, *args, **kwargs)
else:
return super(FavouriteViewSet, self).create(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
+ """
+ Retrieve a Favourite object (login required).
+ """
+
instance = self.get_object()
if is_user_permitted(request, instance.user.id):
return super().retrieve(request)
return Response(
- {'message': 'You are not authorized to view this info.'},
- status=status.HTTP_401_UNAUTHORIZED
+ {"message": "You are not authorized to view this info."},
+ status=status.HTTP_401_UNAUTHORIZED,
)
def destroy(self, request, *args, **kwargs):
+ """
+ Delete a Favourite object (login required).
+ """
+
instance = self.get_object()
if is_user_permitted(request, instance.user.id):
return super().destroy(request)
return Response(
- {'message': 'You are not authorized to perform this action.'},
- status=status.HTTP_401_UNAUTHORIZED
+ {"message": "You are not authorized to perform this action."},
+ status=status.HTTP_401_UNAUTHORIZED,
)
@method_decorator(never_cache)
def list(self, request, *args, **kwargs):
+ """
+ List all Favourites (login required).
+ """
+
queryset = self.get_queryset()
- if request and hasattr(request, 'user'):
+ if request and hasattr(request, "user"):
if request.user.is_authenticated:
queryset = queryset.filter(user__id=request.user.id)
serializer = self.serializer_class(queryset, many=True)
diff --git a/web/language/views/placename.py b/web/language/views/placename.py
index 6574104d4..d0557f56d 100755
--- a/web/language/views/placename.py
+++ b/web/language/views/placename.py
@@ -9,6 +9,8 @@
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import FilterSet
+from drf_yasg.utils import swagger_auto_schema
+from drf_yasg import openapi
from users.models import Administrator
from language.models import Language, PlaceName, Media, PublicArtArtist
@@ -64,6 +66,10 @@ class PlaceNameViewSet(BaseModelViewSet):
@method_decorator(login_required)
def create(self, request, *args, **kwargs):
+ """
+ Create a PlaceName of any `kind` (login required).
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
required_fields_missing = False
@@ -142,6 +148,10 @@ def perform_create(self, serializer):
obj.save()
def update(self, request, *args, **kwargs):
+ """
+ Update a PlaceName object (login/ownership required)
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
placename = PlaceName.objects.get(pk=kwargs.get("pk"))
@@ -185,6 +195,10 @@ def update(self, request, *args, **kwargs):
)
def destroy(self, request, *args, **kwargs):
+ """
+ Delete a PlaceName object (login/ownership required).
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
placename = PlaceName.objects.get(pk=kwargs.get("pk"))
@@ -212,6 +226,10 @@ def destroy(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def verify(self, request, *args, **kwargs):
+ """
+ Sets the status of a PlaceName's status to `VERIFIED` (Django admin access required).
+ """
+
instance = self.get_object()
if request and hasattr(request, "user") and request.user.is_authenticated:
if instance.kind not in ["", "poi"]:
@@ -239,6 +257,10 @@ def verify(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def reject(self, request, *args, **kwargs):
+ """
+ Sets the status of a PlaceName's status to `REJECTED` (Django admin access required).
+ """
+
instance = self.get_object()
if request and hasattr(request, "user") and request.user.is_authenticated:
if instance.kind not in ["", "poi"]:
@@ -270,6 +292,10 @@ def reject(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def flag(self, request, *args, **kwargs):
+ """
+ Sets the status of a PlaceName's status to `FLAGGED`.
+ """
+
instance = self.get_object()
if instance.kind not in ["", "poi"]:
return Response(
@@ -295,6 +321,10 @@ def detail(self, request):
@method_decorator(never_cache)
def retrieve(self, request, *args, **kwargs):
+ """
+ Retrieve a PlaceName object (viewable information may vary)
+ """
+
placename = PlaceName.objects.get(pk=kwargs.get("pk"))
serializer = self.get_serializer(placename)
serializer_data = serializer.data
@@ -365,6 +395,10 @@ def list(self, request, *args, **kwargs):
# GEO LIST APIVIEWS
class PlaceNameGeoList(generics.ListAPIView):
+ """
+ List all POIs, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
PlaceName.objects.exclude(name__icontains="FirstVoices")
.filter(kind__in=["poi", ""], geom__isnull=False)
@@ -394,6 +428,10 @@ def list(self, request, *args, **kwargs):
class ArtGeoList(generics.ListAPIView):
+ """
+ List all arts in a geo format (can be filtered by language).
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -406,13 +444,22 @@ class ArtGeoList(generics.ListAPIView):
)
serializer_class = PlaceNameGeoSerializer
filter_backends = [DjangoFilterBackend]
- filterset_fields = [
- "language",
- ]
+ filterset_fields = ["language"]
# Users can contribute this data, so never cache it.
@method_decorator(never_cache)
- def list(self, request, *args, **kwargs):
+ @swagger_auto_schema(
+ manual_parameters=[
+ openapi.Parameter(
+ "language",
+ openapi.IN_QUERY,
+ description="Filter results by language ID",
+ type=openapi.TYPE_INTEGER,
+ required=False,
+ )
+ ],
+ )
+ def get(self, request, *args, **kwargs):
queryset = get_queryset_for_user(self, request)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
@@ -420,6 +467,10 @@ def list(self, request, *args, **kwargs):
# SEARCH LIST APIVIEWS
class PlaceNameSearchList(BasePlaceNameListAPIView):
+ """
+ List all POIs to be used in the frontend's search bar.
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -431,6 +482,10 @@ class PlaceNameSearchList(BasePlaceNameListAPIView):
class ArtSearchList(BasePlaceNameListAPIView):
+ """
+ List all arts to be used in the frontend's search bar.
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -446,6 +501,10 @@ class ArtSearchList(BasePlaceNameListAPIView):
# ART TYPES
class PublicArtList(BasePlaceNameListAPIView):
+ """
+ List all public arts, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -457,6 +516,10 @@ class PublicArtList(BasePlaceNameListAPIView):
class ArtistList(BasePlaceNameListAPIView):
+ """
+ List all artists, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -468,6 +531,10 @@ class ArtistList(BasePlaceNameListAPIView):
class EventList(BasePlaceNameListAPIView):
+ """
+ List all events, in a geo format, to be used in the frontend's map.
+ """
+
queryset = PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
).filter(kind="event", geom__isnull=False)
@@ -475,6 +542,10 @@ class EventList(BasePlaceNameListAPIView):
class OrganizationList(BasePlaceNameListAPIView):
+ """
+ List all organizations, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -486,6 +557,10 @@ class OrganizationList(BasePlaceNameListAPIView):
class ResourceList(BasePlaceNameListAPIView):
+ """
+ List all resources, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -497,6 +572,10 @@ class ResourceList(BasePlaceNameListAPIView):
class GrantList(BasePlaceNameListAPIView):
+ """
+ List all grants in a geo format (deprecated).
+ """
+
queryset = (
PlaceName.objects.exclude(
Q(name__icontains="FirstVoices") | Q(geom__exact=Point(0.0, 0.0))
@@ -509,6 +588,10 @@ class GrantList(BasePlaceNameListAPIView):
# ARTWORKS
class ArtworkList(generics.ListAPIView):
+ """
+ List all artworks, in a geo format, to be used in the frontend's map.
+ """
+
queryset = (
Media.objects.exclude(Q(placename__name__icontains="FirstVoices"))
.filter(is_artwork=True, placename__geom__isnull=False)
@@ -522,6 +605,10 @@ def list(self, request, *args, **kwargs):
class ArtworkPlaceNameList(generics.ListAPIView):
+ """
+ List all PlaceNames with media attached to it.
+ """
+
queryset = PlaceName.objects.exclude(
Q(medias__isnull=True) | Q(geom__exact=Point(0.0, 0.0))
).only("id", "name", "image", "kind", "geom")
diff --git a/web/users/views.py b/web/users/views.py
index 8f15acc51..7d379eff4 100755
--- a/web/users/views.py
+++ b/web/users/views.py
@@ -257,6 +257,10 @@ def post(self, request):
class ValidateInviteView(APIView):
def post(self, request):
+ """
+ Validates the key in the invitation link sent to artists.
+ """
+
data = request.data
if "email" in data and "key" in data:
From 1d8374b07f159132b9bcdf6d3e2811c8769e724d Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 07:24:31 +0800
Subject: [PATCH 17/36] Finished standard documentation through docstrings
---
web/language/views/community.py | 6 +++---
web/language/views/media.py | 32 ++++++++++++++++++++++++++++++++
web/language/views/others.py | 4 ++++
web/language/views/placename.py | 2 +-
web/language/views/taxonomy.py | 16 ++++++++++++----
web/users/views.py | 27 +++++++++++++++++++++++++--
6 files changed, 77 insertions(+), 10 deletions(-)
diff --git a/web/language/views/community.py b/web/language/views/community.py
index dff251634..1ef6b2fa5 100755
--- a/web/language/views/community.py
+++ b/web/language/views/community.py
@@ -50,7 +50,7 @@ def detail(self, request):
def update(self, request, *args, **kwargs):
"""
- Update Community details (community admin access required).
+ Update a Community object (community admin access required).
This is only accessible to the community's admin configured through the Administrator model.
"""
@@ -210,7 +210,7 @@ def create_membership(self, request, pk):
@action(detail=False)
def list_member_to_verify(self, request):
"""
- Lists all members that are awaiting verification.
+ List all members that are awaiting verification for the user's community (community admin access required).
"""
# 'VERIFIED' or 'REJECTED' members do not need to the verified
@@ -233,7 +233,7 @@ def list_member_to_verify(self, request):
@action(detail=False, methods=["post"])
def verify_member(self, request):
"""
- Sets the status of a user's CommunityMembership to `VERIFIED`.
+ Set the status of a user's CommunityMembership to `VERIFIED`.
"""
if request and hasattr(request, "user"):
diff --git a/web/language/views/media.py b/web/language/views/media.py
index 1b500f135..86410adc5 100755
--- a/web/language/views/media.py
+++ b/web/language/views/media.py
@@ -33,6 +33,10 @@ class MediaViewSet(MediaCustomViewSet, GenericViewSet):
filterset_fields = ["placename", "community"]
def create(self, request, *args, **kwargs):
+ """
+ Create a Media object (automatically set to `VERIFIED` if the creator is a community admin).
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
return super().create(request)
@@ -64,6 +68,10 @@ def perform_create(self, serializer):
obj.save()
def update(self, request, *args, **kwargs):
+ """
+ Update a Media object (login/ownership required).
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
media = Media.objects.get(pk=kwargs.get("pk"))
@@ -90,6 +98,10 @@ def update(self, request, *args, **kwargs):
)
def destroy(self, request, *args, **kwargs):
+ """
+ Destroy a Media object (login/ownership required).
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
media = Media.objects.get(pk=kwargs.get("pk"))
@@ -118,6 +130,10 @@ def destroy(self, request, *args, **kwargs):
@method_decorator(never_cache)
@action(detail=False)
def list_to_verify(self, request):
+ """
+ List all Media that are awaiting verification (community/language admin access required)
+ """
+
# 'VERIFIED' Media do not need to the verified
queryset = (
self.get_queryset()
@@ -153,6 +169,10 @@ def list_to_verify(self, request):
@action(detail=True, methods=["patch"])
def verify(self, request, *args, **kwargs):
+ """
+ Set the Media's status to `VERIFIED` (Django admin access required).
+ """
+
instance = self.get_object()
if request and hasattr(request, "user") and request.user.is_authenticated:
@@ -175,6 +195,10 @@ def verify(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def reject(self, request, *args, **kwargs):
+ """
+ Set the Media's status to `REJECTED` (Django admin access required).
+ """
+
instance = self.get_object()
if request and hasattr(request, "user") and request.user.is_authenticated:
@@ -202,6 +226,10 @@ def reject(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def flag(self, request, *args, **kwargs):
+ """
+ Set the Media's status to `FLAGGED`.
+ """
+
instance = self.get_object()
if instance.status == VERIFIED:
@@ -219,6 +247,10 @@ def flag(self, request, *args, **kwargs):
# Users can contribute this data, so never cache it.
@method_decorator(never_cache)
def list(self, request, *args, **kwargs):
+ """
+ List all Media.
+ """
+
queryset = get_queryset_for_user(self, request)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
diff --git a/web/language/views/others.py b/web/language/views/others.py
index 794058123..0eea36fe4 100755
--- a/web/language/views/others.py
+++ b/web/language/views/others.py
@@ -29,6 +29,10 @@ def perform_create(self, serializer):
@method_decorator(never_cache)
def list(self, request, *args, **kwargs):
+ """
+ List all notifications (login required).
+ """
+
queryset = self.get_queryset()
if request and hasattr(request, "user"):
diff --git a/web/language/views/placename.py b/web/language/views/placename.py
index d0557f56d..83b5ab124 100755
--- a/web/language/views/placename.py
+++ b/web/language/views/placename.py
@@ -227,7 +227,7 @@ def destroy(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def verify(self, request, *args, **kwargs):
"""
- Sets the status of a PlaceName's status to `VERIFIED` (Django admin access required).
+ Set the status of a PlaceName's status to `VERIFIED` (Django admin access required).
"""
instance = self.get_object()
diff --git a/web/language/views/taxonomy.py b/web/language/views/taxonomy.py
index 35880fbc3..bdc0071ad 100755
--- a/web/language/views/taxonomy.py
+++ b/web/language/views/taxonomy.py
@@ -12,22 +12,30 @@
class TaxonomyFilterSet(FilterSet):
- names = ListFilter(field_name='name', lookup_expr='in')
+ names = ListFilter(field_name="name", lookup_expr="in")
class Meta:
model = Taxonomy
- fields = ('names', 'parent')
+ fields = ("names", "parent")
class TaxonomyViewSet(mixins.ListModelMixin, GenericViewSet):
serializer_class = TaxonomySerializer
queryset = Taxonomy.objects.all().order_by(
- F('parent__id',).asc(nulls_first=True),
- F('order',).asc(nulls_last=True))
+ F(
+ "parent__id",
+ ).asc(nulls_first=True),
+ F(
+ "order",
+ ).asc(nulls_last=True),
+ )
filter_backends = [DjangoFilterBackend]
filterset_class = TaxonomyFilterSet
@method_decorator(never_cache)
def list(self, request, *args, **kwargs):
+ """
+ List all Taxonomies to be used in the frontend's filters.
+ """
return super().list(request)
diff --git a/web/users/views.py b/web/users/views.py
index 7d379eff4..4702c9c92 100755
--- a/web/users/views.py
+++ b/web/users/views.py
@@ -52,6 +52,9 @@ class UserViewSet(UserCustomViewSet, GenericViewSet):
@method_decorator(never_cache)
def retrieve(self, request, *args, **kwargs):
+ """
+ Get a User object (only supports retrieving current user info)
+ """
if request and hasattr(request, "user"):
user_id = int(kwargs.get("pk"))
@@ -72,6 +75,10 @@ def retrieve(self, request, *args, **kwargs):
@method_decorator(never_cache)
def partial_update(self, request, *args, **kwargs):
+ """
+ Patch User information
+ """
+
if request and hasattr(request, "user"):
user_id = int(kwargs.get("pk"))
@@ -93,6 +100,10 @@ def partial_update(self, request, *args, **kwargs):
@method_decorator(never_cache)
@action(detail=False)
def auth(self, request):
+ """
+ Retrieve authentication information.
+ """
+
if not request.user.is_authenticated:
return Response({"is_authenticated": False})
@@ -110,8 +121,9 @@ def auth(self, request):
@action(detail=False)
def login(self, request):
"""
- This API expects a JWT from AWS Cognito, which it uses to authenticate our user
+ Allow a user to log in by consuming a JWT from AWS Cognito
"""
+
id_token = request.GET.get("id_token")
result = verify_token(id_token)
if "email" in result:
@@ -139,6 +151,9 @@ def login(self, request):
@action(detail=False)
def logout(self, request):
+ """
+ Log the current User out and invalidates the JWT in Cognito
+ """
# TODO: invalidate the JWT on cognito
logout(request)
return Response({"success": True})
@@ -146,6 +161,10 @@ def logout(self, request):
class ConfirmClaimView(APIView):
def get(self, request):
+ """
+ Get all Artist profiles to be claimed, based on the invite link.
+ """
+
data = request.GET
if "email" in data and "key" in data:
@@ -197,6 +216,10 @@ def get(self, request):
@method_decorator(never_cache, login_required)
def post(self, request):
+ """
+ Confirm Artist profiles claim action, and sets the current user as the creator for said profiles.
+ """
+
data = request.data
if "email" in data and "key" in data and "user_id" in data:
@@ -260,7 +283,7 @@ def post(self, request):
"""
Validates the key in the invitation link sent to artists.
"""
-
+
data = request.data
if "email" in data and "key" in data:
From a680b6d1903678ed04c6037cbd6c8d2237589600 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 07:45:00 +0800
Subject: [PATCH 18/36] Fix user permissions for verify and reject
---
web/language/views/media.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/web/language/views/media.py b/web/language/views/media.py
index 86410adc5..0c010e636 100755
--- a/web/language/views/media.py
+++ b/web/language/views/media.py
@@ -175,7 +175,11 @@ def verify(self, request, *args, **kwargs):
instance = self.get_object()
- if request and hasattr(request, "user") and request.user.is_authenticated:
+ if (
+ request
+ and hasattr(request, "user")
+ and (self.request.user.is_staff or self.request.user.is_superuser)
+ ):
if instance.status == VERIFIED:
return Response(
{"success": False, "message": "Media has already been verified."},
@@ -201,7 +205,11 @@ def reject(self, request, *args, **kwargs):
instance = self.get_object()
- if request and hasattr(request, "user") and request.user.is_authenticated:
+ if (
+ request
+ and hasattr(request, "user")
+ and (self.request.user.is_staff or self.request.user.is_superuser)
+ ):
if instance.status == VERIFIED:
return Response(
{"success": False, "message": "Media has already been verified."},
From 5588a71813b782f8754993609db761de3d2f4856 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 07:56:25 +0800
Subject: [PATCH 19/36] More API documentation updates
---
web/language/views/community.py | 33 ++++++++++++++++++++++++++-------
web/language/views/language.py | 4 ++++
web/language/views/media.py | 4 ++--
3 files changed, 32 insertions(+), 9 deletions(-)
diff --git a/web/language/views/community.py b/web/language/views/community.py
index 1ef6b2fa5..19b61c48b 100755
--- a/web/language/views/community.py
+++ b/web/language/views/community.py
@@ -36,6 +36,9 @@ class CommunityViewSet(BaseModelViewSet):
queryset = Community.objects.all().order_by("name").exclude(point__isnull=True)
def list(self, request, *args, **kwargs):
+ """
+ List all Communities.
+ """
queryset = self.get_queryset()
if "lang" in request.GET:
queryset = queryset.filter(
@@ -44,15 +47,29 @@ def list(self, request, *args, **kwargs):
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
- @method_decorator(never_cache)
- def detail(self, request):
- return super().detail(request)
+ def create(self, request, *args, **kwargs):
+ """
+ Create a Community object (Django admin access required).
+ """
+
+ if (
+ request
+ and hasattr(request, "user")
+ and (self.request.user.is_staff or self.request.user.is_superuser)
+ ):
+ return super().create(request, *args, **kwargs)
+
+ return Response(
+ {
+ "success": False,
+ "message": "Only staff can create communities.",
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
def update(self, request, *args, **kwargs):
"""
Update a Community object (community admin access required).
-
- This is only accessible to the community's admin configured through the Administrator model.
"""
instance = self.get_object()
@@ -67,8 +84,6 @@ def update(self, request, *args, **kwargs):
def destroy(self, request, *args, **kwargs):
"""
Delete a Community object (community admin access required).
-
- This is only accessible to the community's admin configured through the Administrator model.
"""
instance = self.get_object()
@@ -330,6 +345,10 @@ class CommunityLanguageStatsViewSet(BaseModelViewSet):
class ChampionViewSet(BaseModelViewSet):
+ """
+ Get/Create/Update/Delete a Champion object (read only/Django admin access required).
+ """
+
permission_classes = [IsAdminOrReadOnly]
serializer_class = ChampionSerializer
diff --git a/web/language/views/language.py b/web/language/views/language.py
index 0bc1b0228..816f90c5c 100755
--- a/web/language/views/language.py
+++ b/web/language/views/language.py
@@ -18,6 +18,10 @@
class LanguageViewSet(BaseModelViewSet):
+ """
+ Get/Create/Update/Delete a Language object (read only/Django admin access required).
+ """
+
permission_classes = [IsAdminOrReadOnly]
serializer_class = LanguageSerializer
diff --git a/web/language/views/media.py b/web/language/views/media.py
index 0c010e636..2cf473972 100755
--- a/web/language/views/media.py
+++ b/web/language/views/media.py
@@ -192,7 +192,7 @@ def verify(self, request, *args, **kwargs):
return Response(
{
"success": False,
- "message": "Only Administrators can verify contributions.",
+ "message": "Only staff can verify contributions.",
},
status=status.HTTP_403_FORBIDDEN,
)
@@ -227,7 +227,7 @@ def reject(self, request, *args, **kwargs):
return Response(
{
"success": False,
- "message": "Only Administrators can reject contributions.",
+ "message": "Only staff can reject contributions.",
},
status=status.HTTP_403_FORBIDDEN,
)
From 952b24053032c5173c8a16ce7b98971f67e6501f Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 08:04:45 +0800
Subject: [PATCH 20/36] Update permission and docstring for PageViewSet
---
web/web/views.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/web/web/views.py b/web/web/views.py
index 17f0819df..695ad60fd 100755
--- a/web/web/views.py
+++ b/web/web/views.py
@@ -2,12 +2,14 @@
from web.models import Page
from web.serializers import PageSerializer
+from web.permissions import IsAdminOrReadOnly
class PageViewSet(viewsets.ModelViewSet):
"""
- A simple ViewSet for viewing and editing accounts.
+ Get/Create/Update/Delete a Page object (read only/Django admin access required).
"""
+ permission_classes = [IsAdminOrReadOnly]
queryset = Page.objects.all()
serializer_class = PageSerializer
From 3b8b7369186e13660055ff6b21a5e408f7814cc0 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 08:05:05 +0800
Subject: [PATCH 21/36] Update permission and docstring for NotificationViewSet
---
web/language/views/others.py | 25 ++++++-------------------
1 file changed, 6 insertions(+), 19 deletions(-)
diff --git a/web/language/views/others.py b/web/language/views/others.py
index 0eea36fe4..a66873120 100755
--- a/web/language/views/others.py
+++ b/web/language/views/others.py
@@ -4,6 +4,7 @@
from rest_framework import mixins, status
from rest_framework.viewsets import GenericViewSet
from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated
from language.models import Favourite, Notification, Recording
from language.views import BaseModelViewSet
@@ -21,31 +22,17 @@ class RecordingViewSet(BaseModelViewSet):
class NotificationViewSet(BaseModelViewSet):
+ """
+ Get/Create/Update/Delete a Notification object (login required).
+ """
+
serializer_class = NotificationSerializer
queryset = Notification.objects.all()
+ permission_classes = [IsAuthenticated]
def perform_create(self, serializer):
serializer.save(user=self.request.user)
- @method_decorator(never_cache)
- def list(self, request, *args, **kwargs):
- """
- List all notifications (login required).
- """
-
- queryset = self.get_queryset()
-
- if request and hasattr(request, "user"):
- if request.user.is_authenticated:
- queryset = queryset.filter(user__id=request.user.id)
- serializer = self.serializer_class(queryset, many=True)
- return Response(serializer.data)
-
- return Response(
- {"message": "Only logged in users can view theirs favourites"},
- status=status.HTTP_401_UNAUTHORIZED,
- )
-
# To enable only CREATE and DELETE, we create a custom ViewSet class...
class FavouriteCustomViewSet(
From 100476b4bee992ebc92bcab4d30e50bae634cce3 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 08:07:32 +0800
Subject: [PATCH 22/36] Update permission and docstring for RecordingViewSet
---
web/language/views/others.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/web/language/views/others.py b/web/language/views/others.py
index a66873120..56dbc12ae 100755
--- a/web/language/views/others.py
+++ b/web/language/views/others.py
@@ -13,12 +13,17 @@
NotificationSerializer,
RecordingSerializer,
)
-from web.permissions import is_user_permitted
+from web.permissions import is_user_permitted, IsAuthenticatedUserOrReadOnly
class RecordingViewSet(BaseModelViewSet):
+ """
+ Get/Create/Update/Delete a Recording object (read only/login required).
+ """
+
serializer_class = RecordingSerializer
queryset = Recording.objects.all()
+ permission_classes = [IsAuthenticatedUserOrReadOnly]
class NotificationViewSet(BaseModelViewSet):
From a36de032582c2e194629bbd08d774ed8a863c8db Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 08:28:46 +0800
Subject: [PATCH 23/36] Finish first pass on BE API documentation
---
web/grants/views/grant.py | 6 ++++++
web/language/views/community.py | 4 ++++
web/language/views/media.py | 6 ++++++
web/language/views/placename.py | 18 +++++++-----------
web/users/views.py | 4 ++--
5 files changed, 25 insertions(+), 13 deletions(-)
diff --git a/web/grants/views/grant.py b/web/grants/views/grant.py
index a85508437..a781fe734 100644
--- a/web/grants/views/grant.py
+++ b/web/grants/views/grant.py
@@ -20,6 +20,12 @@ class GrantViewSet(
detail_serializer_class = GrantDetailSerializer
lookup_field = "id"
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Retrieve a Grant object.
+ """
+ return super().retrieve(request)
+
def list(self, request, *args, **kwargs):
"""
List all Grants, in a geo format, to be used in the frontend's map.
diff --git a/web/language/views/community.py b/web/language/views/community.py
index 19b61c48b..daf80ac0d 100755
--- a/web/language/views/community.py
+++ b/web/language/views/community.py
@@ -331,6 +331,10 @@ def reject_member(self, request):
class CommunityLanguageStatsViewSet(BaseModelViewSet):
+ """
+ Get/Create/Update/Delete a CommunityLanguageStats object (read only/Django admin access required).
+ """
+
permission_classes = [IsAdminOrReadOnly]
serializer_class = CommunityLanguageStatsSerializer
diff --git a/web/language/views/media.py b/web/language/views/media.py
index 2cf473972..3c3e3791d 100755
--- a/web/language/views/media.py
+++ b/web/language/views/media.py
@@ -32,6 +32,12 @@ class MediaViewSet(MediaCustomViewSet, GenericViewSet):
filter_backends = [DjangoFilterBackend]
filterset_fields = ["placename", "community"]
+ def retrieve(self, request, *args, **kwargs):
+ """
+ Retrieve a Media object.
+ """
+ return super().retrieve(request)
+
def create(self, request, *args, **kwargs):
"""
Create a Media object (automatically set to `VERIFIED` if the creator is a community admin).
diff --git a/web/language/views/placename.py b/web/language/views/placename.py
index 83b5ab124..9fa7e410c 100755
--- a/web/language/views/placename.py
+++ b/web/language/views/placename.py
@@ -358,6 +358,10 @@ def retrieve(self, request, *args, **kwargs):
@method_decorator(never_cache)
@action(detail=False)
def list_to_verify(self, request):
+ """
+ List all POIs that are awaiting verification (community/language admin access required).
+ """
+
# 'VERIFIED' PlaceNames do not need to the verified
queryset = (
self.get_queryset()
@@ -388,6 +392,9 @@ def list_to_verify(self, request):
@method_decorator(never_cache)
def list(self, request, *args, **kwargs):
+ """
+ List all PlaceNames (viewable information may vary).
+ """
queryset = get_queryset_for_user(self, request)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
@@ -448,17 +455,6 @@ class ArtGeoList(generics.ListAPIView):
# Users can contribute this data, so never cache it.
@method_decorator(never_cache)
- @swagger_auto_schema(
- manual_parameters=[
- openapi.Parameter(
- "language",
- openapi.IN_QUERY,
- description="Filter results by language ID",
- type=openapi.TYPE_INTEGER,
- required=False,
- )
- ],
- )
def get(self, request, *args, **kwargs):
queryset = get_queryset_for_user(self, request)
serializer = self.serializer_class(queryset, many=True)
diff --git a/web/users/views.py b/web/users/views.py
index 4702c9c92..c3287560e 100755
--- a/web/users/views.py
+++ b/web/users/views.py
@@ -53,7 +53,7 @@ class UserViewSet(UserCustomViewSet, GenericViewSet):
@method_decorator(never_cache)
def retrieve(self, request, *args, **kwargs):
"""
- Get a User object (only supports retrieving current user info)
+ Retrieve a User object (only supports retrieving current user info).
"""
if request and hasattr(request, "user"):
user_id = int(kwargs.get("pk"))
@@ -76,7 +76,7 @@ def retrieve(self, request, *args, **kwargs):
@method_decorator(never_cache)
def partial_update(self, request, *args, **kwargs):
"""
- Patch User information
+ Partially update User object
"""
if request and hasattr(request, "user"):
From 45e6288274ab21813e9b0fad234a0f55d5ed6f1b Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 09:01:45 +0800
Subject: [PATCH 24/36] Permission improvements and test improvements
---
web/language/tests/tests_notification.py | 89 ++++++++++++++++--------
web/language/views/others.py | 10 ++-
web/web/permissions.py | 9 +++
3 files changed, 77 insertions(+), 31 deletions(-)
diff --git a/web/language/tests/tests_notification.py b/web/language/tests/tests_notification.py
index 7106724e4..a8d64c7b0 100755
--- a/web/language/tests/tests_notification.py
+++ b/web/language/tests/tests_notification.py
@@ -40,6 +40,11 @@ def setUp(self):
super().setUp()
self.community1 = Community.objects.create(name="Test Community 1")
self.language1 = Language.objects.create(name="Test Language 01")
+ self.user_owned_notification = Notification.objects.create(
+ name="User Owned Notification",
+ language=self.language1,
+ user=self.user,
+ )
# ONE TEST TESTS ONLY ONE SCENARIO
@@ -47,47 +52,68 @@ def test_notification_detail(self):
"""
Ensure we can retrieve a newly created notification object.
"""
- test_notification = Notification.objects.create(
- name="Test notification 001",
- language=self.language1,
- )
+ self.assertTrue(self.client.login(username="testuser001", password="password"))
+
response = self.client.get(
- "/api/notification/{}/".format(test_notification.id), format="json"
+ "/api/notification/{}/".format(self.user_owned_notification.id),
+ format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data["name"], "Test notification 001")
+ self.assertEqual(response.data["name"], self.user_owned_notification.name)
def test_notification_list_authorized_access(self):
"""
- Ensure Notification list API route exists
+ Ensure Notification list API is accessible to logged in users
"""
# Must be logged in
- self.client.login(username="testuser001", password="password")
+ self.assertTrue(self.client.login(username="testuser001", password="password"))
response = self.client.get("/api/notification/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- def test_notification_list_unauthorized_access(self):
+ response = self.client.get(
+ f"/api/notification/{self.user_owned_notification.id}/", format="json"
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_notification_list_unauthenticated(self):
"""
- Ensure Notification list API route exists
+ Ensure Notification list API is only accessible to logged in users
"""
response = self.client.get("/api/notification/", format="json")
- self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ response = self.client.get(
+ f"/api/notification/{self.user_owned_notification.id}/", format="json"
+ )
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_notification_list_unauthorized_access(self):
+ """
+ Ensure Notification is not visible to non-owners
+ """
+ self.assertTrue(self.client.login(username="testuser002", password="password"))
+
+ response = self.client.get(
+ f"/api/notification/{self.user_owned_notification.id}/", format="json"
+ )
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_notification_list_different_users(self):
"""
- Ensure Notification API DELETE method API works
+ Ensure only own notifications are visible to the current user
"""
+
# Must be logged in
self.client.login(username="testuser001", password="password")
- # No data so far for the user
+ # 1 data so far for the user (in setUp)
response = self.client.get("/api/notification/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 0)
+ self.assertEqual(len(response.data), 1)
# Creating an object which BELONGS to the user
- # GET must return one object
+ # GET must return two objects
response = self.client.post(
"/api/notification/",
{
@@ -100,45 +126,43 @@ def test_notification_list_different_users(self):
response2 = self.client.get("/api/notification/", format="json")
self.assertEqual(response2.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response2.data), 1)
+ self.assertEqual(len(response2.data), 2)
# Creating an object which DOES NOT BELONG to the user
- # GET must return one object
- Notification.objects.create(
- user=self.user2, name="test notification2"
- )
+ # GET must still return two objects
+ Notification.objects.create(user=self.user2, name="test notification2")
response = self.client.get("/api/notification/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
+ self.assertEqual(len(response.data), 2)
# Creating an object which BELONGS to the user
- # GET must return two objects
+ # GET must return three objects
test_notification3 = Notification.objects.create(
user=self.user, name="test notification3"
)
response = self.client.get("/api/notification/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 2)
+ self.assertEqual(len(response.data), 3)
# Deleting the object which BELONGS to the user
- # GET must return one object
+ # GET must return two object
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.delete(
"/api/notification/{}/".format(created_id1), format="json"
)
response = self.client.get("/api/notification/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
+ self.assertEqual(len(response.data), 2)
# Deleting the object which BELONGS to the user
- # GET must return zero objects
+ # GET must return 1 object
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.delete(
"/api/notification/{}/".format(test_notification3.id), format="json"
)
response = self.client.get("/api/notification/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 0)
+ self.assertEqual(len(response.data), 1)
def test_notification_post_with_language(self):
"""
@@ -190,8 +214,15 @@ def test_notification_delete(self):
"""
Ensure notification API DELETE method API works
"""
- test_notification = Notification.objects.create(name="Test notification 001")
+ self.assertTrue(self.client.login(username="testuser001", password="password"))
+
response = self.client.delete(
- "/api/notification/{}/".format(test_notification.id), format="json"
+ "/api/notification/{}/".format(self.user_owned_notification.id),
+ format="json",
)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+
+# test_notification_delete
+# test_notification_detail
+# test_notification_list_different_users
diff --git a/web/language/views/others.py b/web/language/views/others.py
index 56dbc12ae..d648ce514 100755
--- a/web/language/views/others.py
+++ b/web/language/views/others.py
@@ -13,7 +13,7 @@
NotificationSerializer,
RecordingSerializer,
)
-from web.permissions import is_user_permitted, IsAuthenticatedUserOrReadOnly
+from web.permissions import is_user_permitted, IsAuthenticatedUserOrReadOnly, IsNotificationOwner
class RecordingViewSet(BaseModelViewSet):
@@ -33,7 +33,13 @@ class NotificationViewSet(BaseModelViewSet):
serializer_class = NotificationSerializer
queryset = Notification.objects.all()
- permission_classes = [IsAuthenticated]
+ permission_classes = [IsAuthenticated, IsNotificationOwner]
+
+ @method_decorator(never_cache)
+ def list(self, request, *args, **kwargs):
+ queryset = self.get_queryset().filter(user__id=request.user.id)
+ serializer = self.serializer_class(queryset, many=True)
+ return Response(serializer.data)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
diff --git a/web/web/permissions.py b/web/web/permissions.py
index b59319e66..e68506cd8 100755
--- a/web/web/permissions.py
+++ b/web/web/permissions.py
@@ -45,6 +45,15 @@ def has_permission(self, request, view):
return bool(request.method in SAFE_METHODS or request.user)
+class IsNotificationOwner(BasePermission):
+ """
+ Check if the user making the request is the owner of the Notification
+ """
+
+ def has_object_permission(self, request, view, obj):
+ return obj.user == request.user
+
+
def is_user_permitted(request, pk_to_compare):
"""
Check if a user is permitted to perform an operation
From 4994acf91caa4f1e9f2d7307aeac63660e289897 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 09:04:07 +0800
Subject: [PATCH 25/36] Remove grants test prints
---
web/grants/tests.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/web/grants/tests.py b/web/grants/tests.py
index 120a622cf..b2ecdbcf6 100644
--- a/web/grants/tests.py
+++ b/web/grants/tests.py
@@ -61,7 +61,6 @@ def test_grant_list(self):
"""
response = self.client.get("/api/grants/", format="json")
data = response.data
- print(data)
features = data.get("features")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -87,7 +86,6 @@ def test_grant_category_list(self):
"""
response = self.client.get("/api/grant-categories/", format="json")
data = response.data
- print(data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(data), 1)
From 1d4bfa88df2c82295a641fb6b32f8eeb60a6e0b0 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Wed, 27 Nov 2024 09:08:22 +0800
Subject: [PATCH 26/36] Remove dead imports
---
web/language/models.py | 18 ------------------
web/language/views/community.py | 1 -
web/language/views/placename.py | 2 --
web/web/admin.py | 1 -
web/web/schema.py | 1 -
5 files changed, 23 deletions(-)
diff --git a/web/language/models.py b/web/language/models.py
index 80e1e354a..be1129125 100755
--- a/web/language/models.py
+++ b/web/language/models.py
@@ -28,24 +28,6 @@
from web.models import BaseModel, CulturalModel
from web.utils import get_art_link, get_comm_link, get_place_link, get_admin_email_list
from users.models import User
-from web.models import BaseModel, CulturalModel
-from web.utils import get_art_link, get_comm_link, get_place_link, get_admin_email_list
-from web.constants import (
- FLAGGED,
- UNVERIFIED,
- VERIFIED,
- REJECTED,
- STATUS_DISPLAY,
- ROLE_ADMIN,
- ROLE_MEMBER,
- PUBLIC_ART,
- ORGANIZATION,
- ARTIST,
- EVENT,
- RESOURCE,
- GRANT,
- POI,
-)
class LanguageFamily(BaseModel):
diff --git a/web/language/views/community.py b/web/language/views/community.py
index daf80ac0d..a4c472fb4 100755
--- a/web/language/views/community.py
+++ b/web/language/views/community.py
@@ -4,7 +4,6 @@
from rest_framework.response import Response
from rest_framework.decorators import action
from django_filters.rest_framework import DjangoFilterBackend
-from drf_yasg.utils import swagger_auto_schema
from users.models import User, Administrator
from language.models import (
diff --git a/web/language/views/placename.py b/web/language/views/placename.py
index 9fa7e410c..333b357ac 100755
--- a/web/language/views/placename.py
+++ b/web/language/views/placename.py
@@ -9,8 +9,6 @@
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import FilterSet
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
from users.models import Administrator
from language.models import Language, PlaceName, Media, PublicArtArtist
diff --git a/web/web/admin.py b/web/web/admin.py
index 2abe55dc4..268984bf7 100755
--- a/web/web/admin.py
+++ b/web/web/admin.py
@@ -2,7 +2,6 @@
from django.contrib import admin
-from web.models import Page
from web.models import Page
admin.site.register(Page, MarkdownxModelAdmin)
diff --git a/web/web/schema.py b/web/web/schema.py
index 2f45744f1..291ed95ed 100644
--- a/web/web/schema.py
+++ b/web/web/schema.py
@@ -1,5 +1,4 @@
from drf_yasg.inspectors import SwaggerAutoSchema
-import inspect
class CustomOpenAPISchema(SwaggerAutoSchema):
From 4c3de659476ad9c474de2bf84c21325126eed3e6 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Thu, 28 Nov 2024 06:41:39 +0800
Subject: [PATCH 27/36] Update BE testing documentation
---
README.md | 35 ++++++++++++++++++++++++++++++-----
1 file changed, 30 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 75569ae96..44821f79b 100755
--- a/README.md
+++ b/README.md
@@ -297,7 +297,7 @@ docker-compose exec web python manage.py get_categories
## Testing
-To test frontend:
+### Frontend
The docker container is by default on sleep. Need to comment out `command: sleep 1000000` on `docker-compose.override.yml` then restart the container.
The test container is dependant on the frontend and the web container, and make sure these are running
@@ -308,14 +308,36 @@ docker-compose up test
```
-To test backend API:
+### Backend
+
+For backend testing, we are using Django and Django Rest Framework's built-in testing modules. The test files are either named `tests.py` or `tests_.py`.
+
+Examples:
```
+# Running all tests
+docker-compose exec web sh test.sh
+
+# Testing a specific app
+docker-compose exec web sh test.sh language
+
+# Testing a specific file
+docker-compose exec web sh test.sh language.tests.tests_language
-docker-compose exec web python manage.py test
+# Testing a specific class with multiple tests
+docker-compose exec web sh test.sh language.tests.tests_language.LanguageAPITests
+# Testing a specific test case
+docker-compose exec web sh test.sh language.tests.tests_language.LanguageAPITests.test_language_detail_route_exists
+
+# Coverage test (specifying app/file/class/test also applies)
+docker-compose exec web sh test-cov.sh
```
+Running a coverage test will create a `coverage.xml` file which indicates which lines were executed and which lines were not. This will also generate a report in the terminal which shows a percentage of the total coverage, and the coverage per file, based on the tests executed.
+
+For more information about tests, run `docker-compose exec web python manage.py help test`
+
## Linting
### Python
@@ -323,10 +345,13 @@ docker-compose exec web python manage.py test
We use pylint to detect linting errors in Python. Disclaimer: pylint is only used to detect errors an does not automatically fix them. Linting fixes have to be manually applied by the developer.
```
-# Linting for entire folder
+# Check linting for the entire backend project
+docker-compose exec web sh pylint.sh
+
+# Check linting for an entire folder
docker-compose exec web sh pylint.sh
-# Linting for specific file
+# Check linting for a specific file
docker-compose exec web sh pylint.sh //
```
From 6a280d8b21615cfa7165c493d9da747e886c468c Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Thu, 28 Nov 2024 06:46:40 +0800
Subject: [PATCH 28/36] Format test code better
---
README.md | 24 +++++++++++++++++-------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 44821f79b..91466649b 100755
--- a/README.md
+++ b/README.md
@@ -314,25 +314,35 @@ For backend testing, we are using Django and Django Rest Framework's built-in te
Examples:
+Running all tests:
```
-# Running all tests
docker-compose exec web sh test.sh
+```
-# Testing a specific app
+Testing a specific app
+```
docker-compose exec web sh test.sh language
+```
-# Testing a specific file
+Testing a specific file
+```
docker-compose exec web sh test.sh language.tests.tests_language
+```
-# Testing a specific class with multiple tests
+Testing a specific class with multiple tests
+```
docker-compose exec web sh test.sh language.tests.tests_language.LanguageAPITests
+```
-# Testing a specific test case
+Testing a specific test case
+```
docker-compose exec web sh test.sh language.tests.tests_language.LanguageAPITests.test_language_detail_route_exists
+```
-# Coverage test (specifying app/file/class/test also applies)
-docker-compose exec web sh test-cov.sh
+Coverage test (specifying app/file/class/test also applies)
```
+docker-compose exec web sh test-cov.sh
+``````
Running a coverage test will create a `coverage.xml` file which indicates which lines were executed and which lines were not. This will also generate a report in the terminal which shows a percentage of the total coverage, and the coverage per file, based on the tests executed.
From c85fc41ef190fba9109dcd54c538a43dcec1388a Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Thu, 28 Nov 2024 06:48:35 +0800
Subject: [PATCH 29/36] Better format linting documentation
---
README.md | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 91466649b..6ef7a141d 100755
--- a/README.md
+++ b/README.md
@@ -354,14 +354,18 @@ For more information about tests, run `docker-compose exec web python manage.py
We use pylint to detect linting errors in Python. Disclaimer: pylint is only used to detect errors an does not automatically fix them. Linting fixes have to be manually applied by the developer.
+Check linting for the entire backend project
```
-# Check linting for the entire backend project
docker-compose exec web sh pylint.sh
+```
-# Check linting for an entire folder
+Check linting for an entire folder
+```
docker-compose exec web sh pylint.sh
+```
-# Check linting for a specific file
+Check linting for a specific file
+```
docker-compose exec web sh pylint.sh //
```
From 27f1a3b20bafd32e027aa5f0f8f9b3a9157d43f3 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Thu, 28 Nov 2024 06:51:31 +0800
Subject: [PATCH 30/36] Remove dead section in the readme
---
README.md | 40 ----------------------------------------
1 file changed, 40 deletions(-)
diff --git a/README.md b/README.md
index 6ef7a141d..ddedd4aac 100755
--- a/README.md
+++ b/README.md
@@ -42,46 +42,6 @@ Acquire a database dump. If the file is `db.sql` in your repo root, do:
./docs/restore-pg
```
-For loading arts data in your local environment, acquire a database dump for the data in `fp-artsmap.ca`. If the file is `arts.sql` in your repo root, follow the instructions below:
-
-- Add another service in your docker-compose.override.yml with the following data:
- ```
- arts_db:
- image: mysql
- environment:
- MYSQL_ROOT_PASSWORD: mysql
- MYSQL_USER: mysql
- MYSQL_DATABASE: arts
- MYSQL_PASSWORD: mysql
- MYSQL_ROOT_HOST: '%'
- networks:
- - back-tier
- volumes:
- - mysqldb-files:/var/lib/mysql
- ```
-- Add volume for the new service:
- ```
- volumes:
- ...
- mysqldb-files:
- driver: local
- ```
-- Run the command below in another terminal to start the new service without stopping the others:
- ```
- docker-compose up -d
- ```
-- Restore the `arts.sql` dump:
- ```
- docker cp arts.sql maps_arts_db_1:/tmp
- docker-compose exec arts_db bash
- cd /tmp
- mysql -u mysql -p arts < arts.sql
- ```
-- Trigger load_arts command in web:
- ```
- docker-compose exec web python manage.py load_arts
- ```
-
## Deployment
* We auto-deploy the `master` branch of `https://github.com/First-Peoples-Cultural-Council/maps` to `https://maps.fpcc.ca` nightly.
From d484733f8783b567395181c8f05ff8226d68b291 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Thu, 28 Nov 2024 06:57:11 +0800
Subject: [PATCH 31/36] Move Linting section to Contributing
---
README.md | 51 ++++++++++++++++++++++++++-------------------------
1 file changed, 26 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index ddedd4aac..6ce409c50 100755
--- a/README.md
+++ b/README.md
@@ -165,15 +165,19 @@ _The API writes objects "atomically", meaning only one database row can be edite
## Contributing
-To work on a feature locally, configure your editor to use the `black` code style for Python, and the `prettier` style for Javascript, HTML and CSS. Use the local `.pretterrc` file. If you ever have coding style problems, you can fix them by running:
+To work on a feature locally, configure your editor to use the `black` code style for Python, and the `prettier` style for Javascript, HTML and CSS. Use the local `.pretterrc` file.
-```
+### Linting
-docker-compose exec frontend yarn lint --fix
+#### Frontend
+
+If you ever have coding style problems, you can fix them by running:
+```
+docker-compose exec frontend yarn lint --fix
```
-Vscode settings for automatic linting
+These are the Vscode settings for automatic linting
```
"eslint.validate": [
{
@@ -195,6 +199,24 @@ Vscode settings for automatic linting
"editor.fontSize": 16,
"terminal.integrated.scrollback": 50000
```
+#### Backend
+
+We use pylint to detect linting errors in Python. Disclaimer: pylint is only used to detect errors an does not automatically fix them. Linting fixes have to be manually applied by the developer.
+
+Check linting for the entire backend project
+```
+docker-compose exec web sh pylint.sh
+```
+
+Check linting for an entire folder
+```
+docker-compose exec web sh pylint.sh
+```
+
+Check linting for a specific file
+```
+docker-compose exec web sh pylint.sh //
+```
### Example, add a new database field.
@@ -308,27 +330,6 @@ Running a coverage test will create a `coverage.xml` file which indicates which
For more information about tests, run `docker-compose exec web python manage.py help test`
-## Linting
-
-### Python
-
-We use pylint to detect linting errors in Python. Disclaimer: pylint is only used to detect errors an does not automatically fix them. Linting fixes have to be manually applied by the developer.
-
-Check linting for the entire backend project
-```
-docker-compose exec web sh pylint.sh
-```
-
-Check linting for an entire folder
-```
-docker-compose exec web sh pylint.sh
-```
-
-Check linting for a specific file
-```
-docker-compose exec web sh pylint.sh //
-```
-
### Notifications
The system sends users notifications weekly, including:
From 6064e1f8d81452509b1f03fe11feead677f36d40 Mon Sep 17 00:00:00 2001
From: Justin Carretas
Date: Thu, 28 Nov 2024 07:29:12 +0800
Subject: [PATCH 32/36] Document libraries used in the readme
---
README.md | 24 ++++++++++++++++--------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 6ce409c50..fc45219fc 100755
--- a/README.md
+++ b/README.md
@@ -2,14 +2,6 @@
This is a web map that helps explore Indigenous language data. This README file includes new materials added in Milestones 3 and 2 of this project. [See Milestone 1 deliverables here](./README-MILESTONE1.md).
-## Technology Stack Overview
-
-- Fully Dockerized, and configured with docker-compose.
-- Uses PostgreSQL and PostGIS.
-- API-Driven Django. We don't use Django's templates for anything.
-- Uses Nuxt.js for SEO-friendly modern templates.
-- Proxies all ports through port 80, the default, including websockets, so there's no need to worry about the port of anything when developing.
-
## Installation
Clone the project.
@@ -349,3 +341,19 @@ docker-compose exec web python manage.py test_notifications --email
Date: Thu, 28 Nov 2024 09:08:38 +0800
Subject: [PATCH 33/36] Add a class diagram
---
class_diagram_dec_2024.png | Bin 0 -> 1305164 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 class_diagram_dec_2024.png
diff --git a/class_diagram_dec_2024.png b/class_diagram_dec_2024.png
new file mode 100644
index 0000000000000000000000000000000000000000..31ddedf8eaa9d47fc0730d81389b324633f81ccd
GIT binary patch
literal 1305164
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