diff --git a/README.md b/README.md
index 75569ae9..eb28f8e6 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.
@@ -42,50 +34,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.
- * We auto-deploy the `develop` branch of `https://github.com/countable-web/maps` to `http://maps.fpcc.ca:8080` nightly.
## Public API
@@ -203,17 +151,41 @@ curl --header "Authorization: Token cfc2b213a4adfbae02332fbbfb45ec09e56413a4" --
_The API writes objects "atomically", meaning only one database row can be edited or added per request. This is to help make the API simple and predicable (simple consistent CRUD for each table), as writing inline objects (while convenient) can lead to nontrivial edge cases. (For example, we need conventions on whether to assume anything not included in a PATCH is to be deleted from the set, modified if it includes updates, and should those modifications follow PATCH conventions as well...). For a small single-purpose writable API that wasn't part of our project focus, the atomic method is predictable and simple, allowing our focus to be on other scope._
-## Contributing
+## Uploading Grants
-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 upload new grants, use the `load_grants` command. FPCC will provide a .xlsx file containing a list of grants. The file should have the following columns in the first sheet: Grant, Language, Year, Recipient, Community/Affiliation, Title, Project Brief, Amount, Address, City, Province, Postal Code
+```
+docker-compose exec web python manage.py load_grants --file_name=/link/to/your/file
```
-docker-compose exec frontend yarn lint --fix
+You may also provide a --test flag in order to see the grants being imported and if there are any errors.
+
+```
+docker-compose exec web python manage.py load_grants --file_name=/link/to/your/file --test=1
+```
+After successfully uploading the grants, we will use the `migrate_manytomany_fields` command in order to link these grants to their Language/Community/Artist/Public Art.
+
+```
+docker-compose exec web python manage.py migrate_manytomany_fields
```
-Vscode settings for automatic linting
+## 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.
+
+### Linting
+
+#### Frontend
+
+If you ever have coding style problems, you can fix them by running:
+
+```
+docker-compose exec frontend yarn lint --fix
+```
+
+These are the Vscode settings for automatic linting
```
"eslint.validate": [
{
@@ -235,6 +207,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.
@@ -274,13 +264,6 @@ KNOWN_HOSTS= # `ssh-keyscan ` output
PROD_DEPLOY_KEY= # private key of producion server
```
-`.github/workflows/cd-stage.yml` - The `develop` branch is deployed by GitHub Actions to staging, `fplm.countable.ca` by default.
-Only the countable-web fork has secrets set to deploy here, as follows.
-```
-KNOWN_HOSTS= # `ssh-keyscan ` output
-BOOL= # private key of staging server
-```
-
## Bootstrapping data (Not necessary to run again)
This project was originally ported from a Drupal database, and we have a somewhat generic way of getting things out of Drupal the first time. Doing this requires populating the old database secrets in your docker-compose.override.yml
@@ -297,7 +280,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,27 +291,45 @@ 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`.
-docker-compose exec web python manage.py test
+Examples:
+Running all tests:
+```
+docker-compose exec web sh test.sh
```
-## Linting
+Testing a specific app
+```
+docker-compose exec web sh test.sh language
+```
-### Python
+Testing a specific file
+```
+docker-compose exec web sh test.sh language.tests.tests_language
+```
-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.
+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
```
-# Linting for entire folder
-docker-compose exec web sh pylint.sh
-# Linting for specific file
-docker-compose exec web sh pylint.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.
+
+For more information about tests, run `docker-compose exec web python manage.py help test`
### Notifications
@@ -349,3 +350,19 @@ docker-compose exec web python manage.py test_notifications --email 1190
-
- if is_new_grant:
- if not recipient:
- if title:
- title_data = title.split("- ")
-
- if title_data:
- return title_data[0].strip()
-
- return recipient
-
- def update_category(self, category):
- if category:
- return NEW_CATEGORIES[category]
-
- return category
-
- def update_grant_title(self, category, year, title):
- if category and year:
- category_abbreviation = CATEGORY_ABBREVIATIONS[category]
- year_start = int(year)
- year_end = abs(year_start) % 100 + 1
-
- return "{} {}-{}".format(category_abbreviation, year_start, year_end)
-
- return title
-
- def get_grants(self):
- return self.query("SELECT nid, title FROM node WHERE type='grant';")
-
- def get_year(self, grant_id):
- year = self.query(
- """
- SELECT
- field_grant_award_year_value
- FROM
- node
- LEFT JOIN
- field_data_field_grant_award_year
- ON
- nid=entity_id
- WHERE
- nid=%s;
- """
- % grant_id
- )
- if year:
- return (
- year[0].get("field_grant_award_year_value")
- if year[0].get("field_grant_award_year_value")
- else ""
- )
-
- return ""
-
- def get_affiliation(self, grant_id):
- affiliation = self.query(
- """
- SELECT
- field_grant_affiliation_value
- FROM
- field_data_field_grant_affiliation
- where
- entity_id=%s;
- """
- % grant_id
- )
- if affiliation:
- return affiliation[0].get("field_grant_affiliation_value", "")
-
- return ""
-
- def get_recipient(self, grant_id):
- recipient = self.query(
- """
- SELECT
- title
- FROM
- field_data_field_grant_recipient,
- (
- SELECT
- entity_id, title, type
- FROM
- field_data_field_grant_recipient, node
- WHERE
- field_grant_recipient_nid=nid
- ) AS recipient_node
- WHERE
- field_data_field_grant_recipient.entity_id = recipient_node.entity_id
- AND
- recipient_node.entity_id=%s;
- """
- % grant_id
- )
- if recipient:
- return recipient[0].get("title", "")
-
- recipient = self.query(
- """
- SELECT
- field_grant_recipient_text_value
- FROM
- field_data_field_grant_recipient_text
- WHERE
- entity_id=%s;
- """
- % grant_id
- )
-
- if recipient:
- return recipient[0].get("title", "")
-
- return ""
-
- def get_project_brief(self, grant_id):
- project_brief = self.query(
- """
- SELECT
- body_value
- FROM
- field_data_body
- WHERE
- entity_id=%s;
- """
- % grant_id
- )
- if project_brief:
- return project_brief[0].get("body_value", "")
-
- return ""
-
- def get_location(self, grant_id):
- location = self.query(
- """
- SELECT
- latitude, longitude, street, city, province, postal_code
- FROM
- location
- JOIN
- field_data_field_shared_location
- ON
- lid=field_shared_location_lid
- WHERE
- entity_id=%s;
- """
- % grant_id
- )
- if location:
- return {
- "latitude": location[0].get("latitude", ""),
- "longitude": location[0].get("longitude", ""),
- "address": location[0].get("street", ""),
- "city": location[0].get("city", ""),
- "province": location[0].get("province", ""),
- "postal_code": location[0].get("postal_code", ""),
- }
-
- return None
-
- def get_category(self, grant_id):
- category = self.query(
- """
- SELECT
- name
- FROM
- field_data_field_grant_category
- LEFT JOIN
- taxonomy_term_data
- ON
- field_grant_category_tid=tid
- where
- entity_id=%s;
- """
- % grant_id
- )
- if category:
- return category[0].get("name", "")
-
- return ""
diff --git a/web/grants/tests.py b/web/grants/tests.py
index a39b155a..b2ecdbcf 100644
--- a/web/grants/tests.py
+++ b/web/grants/tests.py
@@ -1 +1,92 @@
# 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
+ 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
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0]["id"], self.test_category.id)
diff --git a/web/grants/views/grant.py b/web/grants/views/grant.py
index 9b25d47f..a781fe73 100644
--- a/web/grants/views/grant.py
+++ b/web/grants/views/grant.py
@@ -20,7 +20,16 @@ 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.
+ """
return super().list(request)
@@ -36,4 +45,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/models.py b/web/language/models.py
index 80e1e354..be112912 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/tests/tests_notification.py b/web/language/tests/tests_notification.py
index 7106724e..a8d64c7b 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/community.py b/web/language/views/community.py
index 8f4698f0..a4c472fb 100755
--- a/web/language/views/community.py
+++ b/web/language/views/community.py
@@ -35,6 +35,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(
@@ -43,11 +46,31 @@ 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).
+ """
+
instance = self.get_object()
if is_user_community_admin(request, instance):
return super().update(request, *args, **kwargs)
@@ -58,6 +81,10 @@ def update(self, request, *args, **kwargs):
)
def destroy(self, request, *args, **kwargs):
+ """
+ Delete a Community object (community admin access required).
+ """
+
instance = self.get_object()
if is_user_community_admin(request, instance):
return super().update(request, *args, **kwargs)
@@ -69,6 +96,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 +156,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 +188,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 +223,10 @@ def create_membership(self, request, pk):
@method_decorator(never_cache)
@action(detail=False)
def list_member_to_verify(self, request):
+ """
+ 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
members = CommunityMember.objects.exclude(status__exact=VERIFIED).exclude(
status__exact=REJECTED
@@ -201,6 +246,10 @@ def list_member_to_verify(self, request):
@action(detail=False, methods=["post"])
def verify_member(self, request):
+ """
+ Set 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 +271,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 +283,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():
@@ -279,6 +330,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
@@ -293,6 +348,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
@@ -302,6 +361,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 +384,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 24890327..816f90c5 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
@@ -34,6 +38,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 +63,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 +87,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 +103,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 +118,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/media.py b/web/language/views/media.py
index 1b500f13..3c3e3791 100755
--- a/web/language/views/media.py
+++ b/web/language/views/media.py
@@ -32,7 +32,17 @@ 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).
+ """
+
if request and hasattr(request, "user"):
if request.user.is_authenticated:
return super().create(request)
@@ -64,6 +74,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 +104,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 +136,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,9 +175,17 @@ 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:
+ 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."},
@@ -168,16 +198,24 @@ 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,
)
@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:
+ 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."},
@@ -195,13 +233,17 @@ 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,
)
@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 +261,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 523c46a2..d648ce51 100755
--- a/web/language/views/others.py
+++ b/web/language/views/others.py
@@ -4,37 +4,45 @@
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
-from language.serializers import FavouriteSerializer, NotificationSerializer, RecordingSerializer
-from web.permissions import is_user_permitted
+from language.serializers import (
+ FavouriteSerializer,
+ NotificationSerializer,
+ RecordingSerializer,
+)
+from web.permissions import is_user_permitted, IsAuthenticatedUserOrReadOnly, IsNotificationOwner
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):
+ """
+ Get/Create/Update/Delete a Notification object (login required).
+ """
+
serializer_class = NotificationSerializer
queryset = Notification.objects.all()
-
- def perform_create(self, serializer):
- serializer.save(user=self.request.user)
+ permission_classes = [IsAuthenticated, IsNotificationOwner]
@method_decorator(never_cache)
def list(self, request, *args, **kwargs):
- queryset = self.get_queryset()
+ queryset = self.get_queryset().filter(user__id=request.user.id)
+ serializer = self.serializer_class(queryset, many=True)
+ return Response(serializer.data)
- 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)
+ def perform_create(self, serializer):
+ serializer.save(user=self.request.user)
# To enable only CREATE and DELETE, we create a custom ViewSet class...
@@ -57,57 +65,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 'media' in request.data.keys():
- media_id = int(request.data['media'])
+ if "place" in request.data.keys() or "media" in request.data.keys():
+
+ 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 6574104d..333b357a 100755
--- a/web/language/views/placename.py
+++ b/web/language/views/placename.py
@@ -64,6 +64,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 +146,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 +193,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 +224,10 @@ def destroy(self, request, *args, **kwargs):
@action(detail=True, methods=["patch"])
def verify(self, request, *args, **kwargs):
+ """
+ Set 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 +255,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 +290,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 +319,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
@@ -328,6 +356,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()
@@ -358,6 +390,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)
@@ -365,6 +400,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 +433,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 +449,11 @@ 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):
+ 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 +461,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 +476,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 +495,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 +510,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 +525,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 +536,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 +551,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 +566,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 +582,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 +599,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/language/views/taxonomy.py b/web/language/views/taxonomy.py
index 35880fbc..bdc0071a 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/requirements.txt b/web/requirements.txt
index 02d07621..9ee3a253 100644
--- a/web/requirements.txt
+++ b/web/requirements.txt
@@ -18,3 +18,6 @@ geojson==2.5.0
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/test-cov.sh b/web/test-cov.sh
new file mode 100755
index 00000000..21ee3579
--- /dev/null
+++ b/web/test-cov.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+coverage run --omit=*/migrations*,*/management*,*/tests* manage.py test "$@"
+coverage report -m
+coverage xml
+sed -i 's/\/code/\./g' coverage.xml
\ No newline at end of file
diff --git a/web/test.sh b/web/test.sh
index 3a80edf1..d7be4b85 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 "$@"
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 e76f9782..00000000
--- 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/models.py b/web/users/models.py
index c259b2d6..1be6495d 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"
diff --git a/web/users/notifications.py b/web/users/notifications.py
deleted file mode 100755
index 3f24db18..00000000
--- a/web/users/notifications.py
+++ /dev/null
@@ -1,142 +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
-
-
-def _format_fpcc(s):
-
- s = s.strip().lower()
- s = re.sub(
- r"\\|\/|>|<|\?|\)|\(|~|!|@|#|$|^|%|&|\*|=|\+|]|}|\[|{|\||;|:|_|\.|,|`|'|\"",
- "",
- s,
- )
- s = re.sub(r"\s+", "-", s)
- return s
-
-
-# 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)
diff --git a/web/users/tests.py b/web/users/tests.py
index 8ff0fbf6..aa77609e 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
diff --git a/web/users/views.py b/web/users/views.py
index c491e424..c3287560 100755
--- a/web/users/views.py
+++ b/web/users/views.py
@@ -52,10 +52,20 @@ class UserViewSet(UserCustomViewSet, GenericViewSet):
@method_decorator(never_cache)
def retrieve(self, request, *args, **kwargs):
+ """
+ Retrieve a User object (only supports retrieving current user info).
+ """
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(
@@ -65,10 +75,21 @@ def retrieve(self, request, *args, **kwargs):
@method_decorator(never_cache)
def partial_update(self, request, *args, **kwargs):
+ """
+ Partially update User object
+ """
+
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(
@@ -77,15 +98,32 @@ def partial_update(self, request, *args, **kwargs):
)
@method_decorator(never_cache)
- def detail(self, request):
- return super().detail(request)
+ @action(detail=False)
+ def auth(self, request):
+ """
+ Retrieve authentication information.
+ """
+
+ 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):
"""
- 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:
@@ -97,8 +135,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,55 +149,22 @@ 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 ?
+ """
+ Log the current User out and invalidates the JWT in Cognito
+ """
+ # TODO: invalidate the JWT on cognito
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):
+ """
+ Get all Artist profiles to be claimed, based on the invite link.
+ """
+
data = request.GET
if "email" in data and "key" in data:
@@ -212,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:
@@ -272,6 +280,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:
diff --git a/web/web/admin.py b/web/web/admin.py
index 2abe55dc..268984bf 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/permissions.py b/web/web/permissions.py
index b59319e6..e68506cd 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
diff --git a/web/web/schema.py b/web/web/schema.py
new file mode 100644
index 00000000..291ed95e
--- /dev/null
+++ b/web/web/schema.py
@@ -0,0 +1,30 @@
+from drf_yasg.inspectors import SwaggerAutoSchema
+
+
+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 = self._sch.get_description(self.path, self.method) or ""
+ 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):
+ 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
diff --git a/web/web/settings.py b/web/web/settings.py
index 9f8710d6..a0405a6f 100644
--- a/web/web/settings.py
+++ b/web/web/settings.py
@@ -59,8 +59,8 @@
"django_filters",
"rest_framework",
"rest_framework.authtoken",
- "rest_framework_swagger",
"rest_framework_gis",
+ "drf_yasg",
"django_apscheduler",
"language",
"grants",
@@ -175,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,
}
diff --git a/web/web/urls.py b/web/web/urls.py
index 753996ae..2112cbd6 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,
diff --git a/web/web/views.py b/web/web/views.py
index 17f0819d..695ad60f 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