aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps/api
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site/apps/api')
-rw-r--r--pydis_site/apps/api/admin.py80
-rw-r--r--pydis_site/apps/api/migrations/0068_split_nomination_tables.py75
-rw-r--r--pydis_site/apps/api/migrations/0069_documentationlink_validators.py25
-rw-r--r--pydis_site/apps/api/models/__init__.py1
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/documentation_link.py17
-rw-r--r--pydis_site/apps/api/models/bot/metricity.py41
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py52
-rw-r--r--pydis_site/apps/api/pagination.py49
-rw-r--r--pydis_site/apps/api/serializers.py25
-rw-r--r--pydis_site/apps/api/tests/test_documentation_links.py15
-rw-r--r--pydis_site/apps/api/tests/test_models.py16
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py118
-rw-r--r--pydis_site/apps/api/tests/test_users.py39
-rw-r--r--pydis_site/apps/api/views.py28
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py5
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py142
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py31
18 files changed, 667 insertions, 94 deletions
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py
index b6fee9d1..449e660e 100644
--- a/pydis_site/apps/api/admin.py
+++ b/pydis_site/apps/api/admin.py
@@ -21,6 +21,7 @@ from .models import (
Role,
User
)
+from .models.bot.nomination import NominationEntry
admin.site.site_header = "Python Discord | Administration"
admin.site.site_title = "Python Discord"
@@ -218,7 +219,7 @@ class NominationActorFilter(admin.SimpleListFilter):
def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
"""Selectable values for viewer to filter by."""
- actor_ids = Nomination.objects.order_by().values_list("actor").distinct()
+ actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct()
actors = User.objects.filter(id__in=actor_ids)
return ((a.id, a.username) for a in actors)
@@ -226,7 +227,10 @@ class NominationActorFilter(admin.SimpleListFilter):
"""Query to filter the list of Users against."""
if not self.value():
return
- return queryset.filter(actor__id=self.value())
+ nomination_ids = NominationEntry.objects.filter(
+ actor__id=self.value()
+ ).values_list("nomination_id").distinct()
+ return queryset.filter(id__in=nomination_ids)
@admin.register(Nomination)
@@ -236,9 +240,6 @@ class NominationAdmin(admin.ModelAdmin):
search_fields = (
"user__name",
"user__id",
- "actor__name",
- "actor__id",
- "reason",
"end_reason"
)
@@ -247,27 +248,25 @@ class NominationAdmin(admin.ModelAdmin):
list_display = (
"user",
"active",
- "reason",
- "actor",
+ "reviewed"
)
fields = (
"user",
"active",
- "actor",
- "reason",
"inserted_at",
"ended_at",
- "end_reason"
+ "end_reason",
+ "reviewed"
)
- # only allow reason fields to be edited.
+ # only allow end reason field to be edited.
readonly_fields = (
"user",
"active",
- "actor",
"inserted_at",
- "ended_at"
+ "ended_at",
+ "reviewed"
)
def has_add_permission(self, *args) -> bool:
@@ -275,6 +274,61 @@ class NominationAdmin(admin.ModelAdmin):
return False
+class NominationEntryActorFilter(admin.SimpleListFilter):
+ """Actor Filter for NominationEntry Admin list page."""
+
+ title = "Actor"
+ parameter_name = "actor"
+
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ """Selectable values for viewer to filter by."""
+ actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct()
+ actors = User.objects.filter(id__in=actor_ids)
+ return ((a.id, a.username) for a in actors)
+
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ """Query to filter the list of Users against."""
+ if not self.value():
+ return
+ return queryset.filter(actor__id=self.value())
+
+
[email protected](NominationEntry)
+class NominationEntryAdmin(admin.ModelAdmin):
+ """Admin formatting for the NominationEntry model."""
+
+ search_fields = (
+ "actor__name",
+ "actor__id",
+ "reason",
+ )
+
+ list_filter = (NominationEntryActorFilter,)
+
+ list_display = (
+ "nomination",
+ "actor",
+ )
+
+ fields = (
+ "nomination",
+ "actor",
+ "reason",
+ "inserted_at",
+ )
+
+ # only allow reason field to be edited
+ readonly_fields = (
+ "nomination",
+ "actor",
+ "inserted_at",
+ )
+
+ def has_add_permission(self, request: HttpRequest) -> bool:
+ """Disable adding new nomination entry from admin."""
+ return False
+
+
@admin.register(OffTopicChannelName)
class OffTopicChannelNameAdmin(admin.ModelAdmin):
"""Admin formatting for the OffTopicChannelName model."""
diff --git a/pydis_site/apps/api/migrations/0068_split_nomination_tables.py b/pydis_site/apps/api/migrations/0068_split_nomination_tables.py
new file mode 100644
index 00000000..79825ed7
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0068_split_nomination_tables.py
@@ -0,0 +1,75 @@
+# Generated by Django 3.0.11 on 2021-02-21 15:32
+
+from django.apps.registry import Apps
+from django.db import backends, migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+import django.db.models.deletion
+import pydis_site.apps.api.models.mixins
+
+
+def migrate_nominations(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ Nomination = apps.get_model("api", "Nomination")
+ NominationEntry = apps.get_model("api", "NominationEntry")
+
+ for nomination in Nomination.objects.all():
+ nomination_entry = NominationEntry(
+ nomination=nomination,
+ actor=nomination.actor,
+ reason=nomination.reason,
+ inserted_at=nomination.inserted_at
+ )
+ nomination_entry.save()
+
+
+def unmigrate_nominations(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ Nomination = apps.get_model("api", "Nomination")
+ NominationEntry = apps.get_model("api", "NominationEntry")
+
+ for entry in NominationEntry.objects.all():
+ nomination = Nomination.objects.get(pk=entry.nomination.id)
+ nomination.actor = entry.actor
+ nomination.reason = entry.reason
+ nomination.inserted_at = entry.inserted_at
+
+ nomination.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0067_add_voice_ban_infraction_type'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='NominationEntry',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('reason', models.TextField(blank=True, help_text='Why the actor nominated this user.', default="")),
+ ('inserted_at',
+ models.DateTimeField(auto_now_add=True, help_text='The creation date of this nomination entry.')),
+ ('actor', models.ForeignKey(help_text='The staff member that nominated this user.',
+ on_delete=django.db.models.deletion.CASCADE, related_name='nomination_set',
+ to='api.User')),
+ ('nomination', models.ForeignKey(help_text='The nomination this entry belongs to.',
+ on_delete=django.db.models.deletion.CASCADE, to='api.Nomination',
+ related_name='entries')),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ options={'ordering': ('-inserted_at',), 'verbose_name_plural': 'nomination entries'}
+ ),
+ migrations.RunPython(migrate_nominations, unmigrate_nominations),
+ migrations.RemoveField(
+ model_name='nomination',
+ name='actor',
+ ),
+ migrations.RemoveField(
+ model_name='nomination',
+ name='reason',
+ ),
+ migrations.AddField(
+ model_name='nomination',
+ name='reviewed',
+ field=models.BooleanField(default=False, help_text='Whether a review was made.'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0069_documentationlink_validators.py b/pydis_site/apps/api/migrations/0069_documentationlink_validators.py
new file mode 100644
index 00000000..347c0e1a
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0069_documentationlink_validators.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.0.11 on 2021-03-26 18:21
+
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.bot.documentation_link
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0068_split_nomination_tables'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='documentationlink',
+ name='base_url',
+ field=models.URLField(help_text='The base URL from which documentation will be available for this project. Used to generate links to various symbols within this package.', validators=[pydis_site.apps.api.models.bot.documentation_link.ends_with_slash_validator]),
+ ),
+ migrations.AlterField(
+ model_name='documentationlink',
+ name='package',
+ field=models.CharField(help_text='The Python package name that this documentation link belongs to.', max_length=50, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(message='Package names can only consist of lowercase a-z letters, digits, and underscores.', regex='^[a-z0-9_]+$')]),
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index 0a8c90f6..fd5bf220 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -8,6 +8,7 @@ from .bot import (
Message,
MessageDeletionContext,
Nomination,
+ NominationEntry,
OffensiveMessage,
OffTopicChannelName,
Reminder,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index 1673b434..ac864de3 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -6,7 +6,7 @@ from .documentation_link import DocumentationLink
from .infraction import Infraction
from .message import Message
from .message_deletion_context import MessageDeletionContext
-from .nomination import Nomination
+from .nomination import Nomination, NominationEntry
from .off_topic_channel_name import OffTopicChannelName
from .offensive_message import OffensiveMessage
from .reminder import Reminder
diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py
index 2a0ce751..3dcc71fc 100644
--- a/pydis_site/apps/api/models/bot/documentation_link.py
+++ b/pydis_site/apps/api/models/bot/documentation_link.py
@@ -1,7 +1,20 @@
+from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
from django.db import models
from pydis_site.apps.api.models.mixins import ModelReprMixin
+package_name_validator = RegexValidator(
+ regex=r"^[a-z0-9_]+$",
+ message="Package names can only consist of lowercase a-z letters, digits, and underscores."
+)
+
+
+def ends_with_slash_validator(string: str) -> None:
+ """Raise a ValidationError if `string` does not end with a slash."""
+ if not string.endswith("/"):
+ raise ValidationError("The entered URL must end with a slash.")
+
class DocumentationLink(ModelReprMixin, models.Model):
"""A documentation link used by the `!docs` command of the bot."""
@@ -9,13 +22,15 @@ class DocumentationLink(ModelReprMixin, models.Model):
package = models.CharField(
primary_key=True,
max_length=50,
+ validators=(package_name_validator,),
help_text="The Python package name that this documentation link belongs to."
)
base_url = models.URLField(
help_text=(
"The base URL from which documentation will be available for this project. "
"Used to generate links to various symbols within this package."
- )
+ ),
+ validators=(ends_with_slash_validator,)
)
inventory_url = models.URLField(
help_text="The URL at which the Sphinx inventory is available for this package."
diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py
index cae630f1..5daa5c66 100644
--- a/pydis_site/apps/api/models/bot/metricity.py
+++ b/pydis_site/apps/api/models/bot/metricity.py
@@ -1,3 +1,5 @@
+from typing import List, Tuple
+
from django.db import connections
BLOCK_INTERVAL = 10 * 60 # 10 minute blocks
@@ -89,3 +91,42 @@ class Metricity:
raise NotFound()
return values[0]
+
+ def top_channel_activity(self, user_id: str) -> List[Tuple[str, int]]:
+ """
+ Query the top three channels in which the user is most active.
+
+ Help channels are grouped under "the help channels",
+ and off-topic channels are grouped under "off-topic".
+ """
+ self.cursor.execute(
+ """
+ SELECT
+ CASE
+ WHEN channels.name ILIKE 'help-%%' THEN 'the help channels'
+ WHEN channels.name ILIKE 'ot%%' THEN 'off-topic'
+ WHEN channels.name ILIKE '%%voice%%' THEN 'voice chats'
+ ELSE channels.name
+ END,
+ COUNT(1)
+ FROM
+ messages
+ LEFT JOIN channels ON channels.id = messages.channel_id
+ WHERE
+ author_id = '%s' AND NOT messages.is_deleted
+ GROUP BY
+ 1
+ ORDER BY
+ 2 DESC
+ LIMIT
+ 3;
+ """,
+ [user_id]
+ )
+
+ values = self.cursor.fetchall()
+
+ if not values:
+ raise NotFound()
+
+ return values
diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py
index 11b9e36e..221d8534 100644
--- a/pydis_site/apps/api/models/bot/nomination.py
+++ b/pydis_site/apps/api/models/bot/nomination.py
@@ -5,23 +5,12 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin
class Nomination(ModelReprMixin, models.Model):
- """A helper nomination created by staff."""
+ """A general helper nomination information created by staff."""
active = models.BooleanField(
default=True,
help_text="Whether this nomination is still relevant."
)
- actor = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- help_text="The staff member that nominated this user.",
- related_name='nomination_set'
- )
- reason = models.TextField(
- help_text="Why this user was nominated.",
- null=True,
- blank=True
- )
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
@@ -42,6 +31,10 @@ class Nomination(ModelReprMixin, models.Model):
help_text="When the nomination was ended.",
null=True
)
+ reviewed = models.BooleanField(
+ default=False,
+ help_text="Whether a review was made."
+ )
def __str__(self):
"""Representation that makes the target and state of the nomination immediately evident."""
@@ -52,3 +45,38 @@ class Nomination(ModelReprMixin, models.Model):
"""Set the ordering of nominations to most recent first."""
ordering = ("-inserted_at",)
+
+
+class NominationEntry(ModelReprMixin, models.Model):
+ """A nomination entry created by a single staff member."""
+
+ nomination = models.ForeignKey(
+ Nomination,
+ on_delete=models.CASCADE,
+ help_text="The nomination this entry belongs to.",
+ related_name="entries"
+ )
+ actor = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The staff member that nominated this user.",
+ related_name='nomination_set'
+ )
+ reason = models.TextField(
+ help_text="Why the actor nominated this user.",
+ default="",
+ blank=True
+ )
+ inserted_at = models.DateTimeField(
+ auto_now_add=True,
+ help_text="The creation date of this nomination entry."
+ )
+
+ class Meta:
+ """Meta options for NominationEntry model."""
+
+ verbose_name_plural = "nomination entries"
+
+ # Set default ordering here to latest first
+ # so we don't need to define it everywhere
+ ordering = ("-inserted_at",)
diff --git a/pydis_site/apps/api/pagination.py b/pydis_site/apps/api/pagination.py
new file mode 100644
index 00000000..2a325460
--- /dev/null
+++ b/pydis_site/apps/api/pagination.py
@@ -0,0 +1,49 @@
+import typing
+
+from rest_framework.pagination import LimitOffsetPagination
+from rest_framework.response import Response
+
+
+class LimitOffsetPaginationExtended(LimitOffsetPagination):
+ """
+ Extend LimitOffsetPagination to customise the default response.
+
+ For example:
+
+ ## Default response
+ >>> {
+ ... "count": 1,
+ ... "next": None,
+ ... "previous": None,
+ ... "results": [{
+ ... "id": 6,
+ ... "inserted_at": "2021-01-26T21:13:35.477879Z",
+ ... "expires_at": None,
+ ... "active": False,
+ ... "user": 1,
+ ... "actor": 2,
+ ... "type": "warning",
+ ... "reason": null,
+ ... "hidden": false
+ ... }]
+ ... }
+
+ ## Required response
+ >>> [{
+ ... "id": 6,
+ ... "inserted_at": "2021-01-26T21:13:35.477879Z",
+ ... "expires_at": None,
+ ... "active": False,
+ ... "user": 1,
+ ... "actor": 2,
+ ... "type": "warning",
+ ... "reason": None,
+ ... "hidden": False
+ ... }]
+ """
+
+ default_limit = 100
+
+ def get_paginated_response(self, data: typing.Any) -> Response:
+ """Override to skip metadata i.e. `count`, `next`, and `previous`."""
+ return Response(data)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 10eb3839..f47bedca 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -20,6 +20,7 @@ from .models import (
Infraction,
MessageDeletionContext,
Nomination,
+ NominationEntry,
OffTopicChannelName,
OffensiveMessage,
Reminder,
@@ -338,16 +339,36 @@ class UserSerializer(ModelSerializer):
raise ValidationError({"id": ["User with ID already present."]})
+class NominationEntrySerializer(ModelSerializer):
+ """A class providing (de-)serialization of `NominationEntry` instances."""
+
+ # We need to define it here, because we don't want that nomination ID
+ # return inside nomination response entry, because ID is already available
+ # as top-level field. Queryset is required if field is not read only.
+ nomination = PrimaryKeyRelatedField(
+ queryset=Nomination.objects.all(),
+ write_only=True
+ )
+
+ class Meta:
+ """Metadata defined for the Django REST framework."""
+
+ model = NominationEntry
+ fields = ('nomination', 'actor', 'reason', 'inserted_at')
+
+
class NominationSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Nomination` instances."""
+ entries = NominationEntrySerializer(many=True, read_only=True)
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = Nomination
fields = (
- 'id', 'active', 'actor', 'reason', 'user',
- 'inserted_at', 'end_reason', 'ended_at')
+ 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries'
+ )
class OffensiveMessageSerializer(ModelSerializer):
diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py
index e560a2fd..39fb08f3 100644
--- a/pydis_site/apps/api/tests/test_documentation_links.py
+++ b/pydis_site/apps/api/tests/test_documentation_links.py
@@ -60,7 +60,7 @@ class DetailLookupDocumentationLinkAPITests(APISubdomainTestCase):
def setUpTestData(cls):
cls.doc_link = DocumentationLink.objects.create(
package='testpackage',
- base_url='https://example.com',
+ base_url='https://example.com/',
inventory_url='https://example.com'
)
@@ -108,6 +108,17 @@ class DetailLookupDocumentationLinkAPITests(APISubdomainTestCase):
self.assertEqual(response.status_code, 400)
+ def test_create_invalid_package_name_returns_400(self):
+ test_cases = ("InvalidPackage", "invalid package", "i\u0150valid")
+ for case in test_cases:
+ with self.subTest(package_name=case):
+ body = self.doc_json.copy()
+ body['package'] = case
+ url = reverse('bot:documentationlink-list', host='api')
+ response = self.client.post(url, data=body)
+
+ self.assertEqual(response.status_code, 400)
+
class DocumentationLinkCreationTests(APISubdomainTestCase):
def setUp(self):
@@ -115,7 +126,7 @@ class DocumentationLinkCreationTests(APISubdomainTestCase):
self.body = {
'package': 'example',
- 'base_url': 'https://example.com',
+ 'base_url': 'https://example.com/',
'inventory_url': 'https://docs.example.com'
}
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 853e6621..66052e01 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -10,6 +10,7 @@ from pydis_site.apps.api.models import (
Message,
MessageDeletionContext,
Nomination,
+ NominationEntry,
OffTopicChannelName,
OffensiveMessage,
Reminder,
@@ -37,17 +38,11 @@ class StringDunderMethodTests(SimpleTestCase):
def setUp(self):
self.nomination = Nomination(
id=123,
- actor=User(
- id=9876,
- name='Mr. Hemlock',
- discriminator=6666,
- ),
user=User(
id=9876,
name="Hemlock's Cat",
discriminator=7777,
),
- reason="He purrrrs like the best!",
)
self.objects = (
@@ -135,6 +130,15 @@ class StringDunderMethodTests(SimpleTestCase):
),
content="oh no",
expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc)
+ ),
+ NominationEntry(
+ nomination_id=self.nomination.id,
+ actor=User(
+ id=9876,
+ name='Mr. Hemlock',
+ discriminator=6666,
+ ),
+ reason="He purrrrs like the best!",
)
)
diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py
index b37135f8..9cefbd8f 100644
--- a/pydis_site/apps/api/tests/test_nominations.py
+++ b/pydis_site/apps/api/tests/test_nominations.py
@@ -3,7 +3,7 @@ from datetime import datetime as dt, timedelta, timezone
from django_hosts.resolvers import reverse
from .base import APISubdomainTestCase
-from ..models import Nomination, User
+from ..models import Nomination, NominationEntry, User
class CreationTests(APISubdomainTestCase):
@@ -14,6 +14,11 @@ class CreationTests(APISubdomainTestCase):
name='joe dart',
discriminator=1111,
)
+ cls.user2 = User.objects.create(
+ id=9876,
+ name='Who?',
+ discriminator=1234
+ )
def test_accepts_valid_data(self):
url = reverse('bot:nomination-list', host='api')
@@ -27,17 +32,39 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
nomination = Nomination.objects.get(id=response.json()['id'])
+ nomination_entry = NominationEntry.objects.get(
+ nomination_id=nomination.id,
+ actor_id=self.user.id
+ )
self.assertAlmostEqual(
nomination.inserted_at,
dt.now(timezone.utc),
delta=timedelta(seconds=2)
)
self.assertEqual(nomination.user.id, data['user'])
- self.assertEqual(nomination.actor.id, data['actor'])
- self.assertEqual(nomination.reason, data['reason'])
+ self.assertEqual(nomination_entry.reason, data['reason'])
self.assertEqual(nomination.active, True)
- def test_returns_400_on_second_active_nomination(self):
+ def test_returns_200_on_second_active_nomination_by_different_user(self):
+ url = reverse('bot:nomination-list', host='api')
+ first_data = {
+ 'actor': self.user.id,
+ 'reason': 'Joe Dart on Fender Bass',
+ 'user': self.user.id,
+ }
+ second_data = {
+ 'actor': self.user2.id,
+ 'reason': 'Great user',
+ 'user': self.user.id
+ }
+
+ response1 = self.client.post(url, data=first_data)
+ self.assertEqual(response1.status_code, 201)
+
+ response2 = self.client.post(url, data=second_data)
+ self.assertEqual(response2.status_code, 201)
+
+ def test_returns_400_on_second_active_nomination_by_existing_nominator(self):
url = reverse('bot:nomination-list', host='api')
data = {
'actor': self.user.id,
@@ -51,7 +78,7 @@ class CreationTests(APISubdomainTestCase):
response2 = self.client.post(url, data=data)
self.assertEqual(response2.status_code, 400)
self.assertEqual(response2.json(), {
- 'active': ['There can only be one active nomination.']
+ 'actor': ['This actor has already endorsed this nomination.']
})
def test_returns_400_for_missing_user(self):
@@ -189,30 +216,40 @@ class NominationTests(APISubdomainTestCase):
)
cls.active_nomination = Nomination.objects.create(
- user=cls.user,
+ user=cls.user
+ )
+ cls.active_nomination_entry = NominationEntry.objects.create(
+ nomination=cls.active_nomination,
actor=cls.user,
reason="He's pretty funky"
)
cls.inactive_nomination = Nomination.objects.create(
user=cls.user,
- actor=cls.user,
- reason="He's pretty funky",
active=False,
end_reason="His neck couldn't hold the funk",
ended_at="5018-11-20T15:52:00+00:00"
)
+ cls.inactive_nomination_entry = NominationEntry.objects.create(
+ nomination=cls.inactive_nomination,
+ actor=cls.user,
+ reason="He's pretty funky"
+ )
- def test_returns_200_update_reason_on_active(self):
+ def test_returns_200_update_reason_on_active_with_actor(self):
url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api')
data = {
- 'reason': "He's one funky duck"
+ 'reason': "He's one funky duck",
+ 'actor': self.user.id
}
response = self.client.patch(url, data=data)
self.assertEqual(response.status_code, 200)
- nomination = Nomination.objects.get(id=response.json()['id'])
- self.assertEqual(nomination.reason, data['reason'])
+ nomination_entry = NominationEntry.objects.get(
+ nomination_id=response.json()['id'],
+ actor_id=self.user.id
+ )
+ self.assertEqual(nomination_entry.reason, data['reason'])
def test_returns_400_on_frozen_field_update(self):
url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api')
@@ -241,14 +278,18 @@ class NominationTests(APISubdomainTestCase):
def test_returns_200_update_reason_on_inactive(self):
url = reverse('bot:nomination-detail', args=(self.inactive_nomination.id,), host='api')
data = {
- 'reason': "He's one funky duck"
+ 'reason': "He's one funky duck",
+ 'actor': self.user.id
}
response = self.client.patch(url, data=data)
self.assertEqual(response.status_code, 200)
- nomination = Nomination.objects.get(id=response.json()['id'])
- self.assertEqual(nomination.reason, data['reason'])
+ nomination_entry = NominationEntry.objects.get(
+ nomination_id=response.json()['id'],
+ actor_id=self.user.id
+ )
+ self.assertEqual(nomination_entry.reason, data['reason'])
def test_returns_200_update_end_reason_on_inactive(self):
url = reverse('bot:nomination-detail', args=(self.inactive_nomination.id,), host='api')
@@ -442,3 +483,50 @@ class NominationTests(APISubdomainTestCase):
infractions = response.json()
self.assertEqual(len(infractions), 2)
+
+ def test_patch_nomination_set_reviewed_of_active_nomination(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reviewed': True}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_patch_nomination_set_reviewed_of_inactive_nomination(self):
+ url = reverse('api:nomination-detail', args=(self.inactive_nomination.id,), host='api')
+ data = {'reviewed': True}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'reviewed': ['This field cannot be set if the nomination is inactive.']
+ })
+
+ def test_patch_nomination_set_reviewed_and_end(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reviewed': True, 'active': False, 'end_reason': "What?"}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'reviewed': ['This field cannot be set while you are ending a nomination.']
+ })
+
+ def test_modifying_reason_without_actor(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reason': 'That is my reason!'}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'actor': ['This field is required when editing the reason.']
+ })
+
+ def test_modifying_reason_with_unknown_actor(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reason': 'That is my reason!', 'actor': 90909090909090}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'actor': ["The actor doesn't exist or has not nominated the user."]
+ })
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
index 69bbfefc..c43b916a 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -410,7 +410,7 @@ class UserMetricityTests(APISubdomainTestCase):
joined_at = "foo"
total_messages = 1
total_blocks = 1
- self.mock_metricity_user(joined_at, total_messages, total_blocks)
+ self.mock_metricity_user(joined_at, total_messages, total_blocks, [])
# When
url = reverse('bot:user-metricity-data', args=[0], host='api')
@@ -436,13 +436,24 @@ class UserMetricityTests(APISubdomainTestCase):
# Then
self.assertEqual(response.status_code, 404)
+ def test_no_metricity_user_for_review(self):
+ # Given
+ self.mock_no_metricity_user()
+
+ # When
+ url = reverse('bot:user-metricity-review-data', args=[0], host='api')
+ response = self.client.get(url)
+
+ # Then
+ self.assertEqual(response.status_code, 404)
+
def test_metricity_voice_banned(self):
cases = [
{'exception': None, 'voice_banned': True},
{'exception': ObjectDoesNotExist, 'voice_banned': False},
]
- self.mock_metricity_user("foo", 1, 1)
+ self.mock_metricity_user("foo", 1, 1, [["bar", 1]])
for case in cases:
with self.subTest(exception=case['exception'], voice_banned=case['voice_banned']):
@@ -455,7 +466,27 @@ class UserMetricityTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["voice_banned"], case["voice_banned"])
- def mock_metricity_user(self, joined_at, total_messages, total_blocks):
+ def test_metricity_review_data(self):
+ # Given
+ joined_at = "foo"
+ total_messages = 10
+ total_blocks = 1
+ channel_activity = [["bar", 4], ["buzz", 6]]
+ self.mock_metricity_user(joined_at, total_messages, total_blocks, channel_activity)
+
+ # When
+ url = reverse('bot:user-metricity-review-data', args=[0], host='api')
+ response = self.client.get(url)
+
+ # Then
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), {
+ "joined_at": joined_at,
+ "top_channel_activity": channel_activity,
+ "total_messages": total_messages
+ })
+
+ def mock_metricity_user(self, joined_at, total_messages, total_blocks, top_channel_activity):
patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
self.metricity = patcher.start()
self.addCleanup(patcher.stop)
@@ -463,6 +494,7 @@ class UserMetricityTests(APISubdomainTestCase):
self.metricity.user.return_value = dict(joined_at=joined_at)
self.metricity.total_messages.return_value = total_messages
self.metricity.total_message_blocks.return_value = total_blocks
+ self.metricity.top_channel_activity.return_value = top_channel_activity
def mock_no_metricity_user(self):
patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
@@ -472,3 +504,4 @@ class UserMetricityTests(APISubdomainTestCase):
self.metricity.user.side_effect = NotFound()
self.metricity.total_messages.side_effect = NotFound()
self.metricity.total_message_blocks.side_effect = NotFound()
+ self.metricity.top_channel_activity.side_effect = NotFound()
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 0d126051..816463f6 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -121,30 +121,34 @@ class RulesView(APIView):
return Response([
(
- f"Follow the {discord_community_guidelines} and {discord_tos}."
+ f"Follow the {pydis_coc}."
),
(
- f"Follow the {pydis_coc}."
+ f"Follow the {discord_community_guidelines} and {discord_tos}."
),
(
- "Listen to and respect staff members and their instructions."
+ "Respect staff members and listen to their instructions."
),
(
- "This is an English-speaking server, "
- "so please speak English to the best of your ability."
+ "Use English to the best of your ability. "
+ "Be polite if someone speaks English imperfectly."
),
(
"Do not provide or request help on projects that may break laws, "
- "breach terms of services, be considered malicious or inappropriate. "
- "Do not help with ongoing exams. Do not provide or request solutions "
- "for graded assignments, although general guidance is okay."
+ "breach terms of services, or are malicious or inappropriate."
+ ),
+ (
+ "Do not post unapproved advertising."
+ ),
+ (
+ "Keep discussions relevant to the channel topic. "
+ "Each channel's description tells you the topic."
),
(
- "No spamming or unapproved advertising, including requests for paid work. "
- "Open-source projects can be shared with others in #python-general and "
- "code reviews can be asked for in a help channel."
+ "Do not help with ongoing exams. When helping with homework, "
+ "help people learn how to do the assignment without doing it for them."
),
(
- "Keep discussions relevant to channel topics and guidelines."
+ "Do not offer or ask for paid work of any kind."
),
])
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
index 423e806e..bd512ddd 100644
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -13,6 +13,7 @@ from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from pydis_site.apps.api.models.bot.infraction import Infraction
+from pydis_site.apps.api.pagination import LimitOffsetPaginationExtended
from pydis_site.apps.api.serializers import (
ExpandedInfractionSerializer,
InfractionSerializer
@@ -38,6 +39,8 @@ class InfractionViewSet(
- **active** `bool`: whether the infraction is still active
- **actor__id** `int`: snowflake of the user which applied the infraction
- **hidden** `bool`: whether the infraction is a shadow infraction
+ - **limit** `int`: number of results return per page (default 100)
+ - **offset** `int`: the initial index from which to return the results (default 0)
- **search** `str`: regular expression applied to the infraction's reason
- **type** `str`: the type of the infraction
- **user__id** `int`: snowflake of the user to which the infraction was applied
@@ -46,6 +49,7 @@ class InfractionViewSet(
Invalid query parameters are ignored.
#### Response format
+ Response is paginated but the result is returned without any pagination metadata.
>>> [
... {
... 'id': 5,
@@ -133,6 +137,7 @@ class InfractionViewSet(
serializer_class = InfractionSerializer
queryset = Infraction.objects.all()
+ pagination_class = LimitOffsetPaginationExtended
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type')
search_fields = ('$reason',)
diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py
index cf6e262f..144daab0 100644
--- a/pydis_site/apps/api/viewsets/bot/nomination.py
+++ b/pydis_site/apps/api/viewsets/bot/nomination.py
@@ -14,8 +14,8 @@ from rest_framework.mixins import (
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
-from pydis_site.apps.api.models.bot import Nomination
-from pydis_site.apps.api.serializers import NominationSerializer
+from pydis_site.apps.api.models.bot import Nomination, NominationEntry
+from pydis_site.apps.api.serializers import NominationEntrySerializer, NominationSerializer
class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
@@ -29,7 +29,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
#### Query parameters
- **active** `bool`: whether the nomination is still active
- - **actor__id** `int`: snowflake of the user who nominated the user
- **user__id** `int`: snowflake of the user who received the nomination
- **ordering** `str`: comma-separated sequence of fields to order the returned results
@@ -40,12 +39,18 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
... {
... 'id': 1,
... 'active': false,
- ... 'actor': 336843820513755157,
- ... 'reason': 'They know how to explain difficult concepts',
... 'user': 336843820513755157,
... 'inserted_at': '2019-04-25T14:02:37.775587Z',
... 'end_reason': 'They were helpered after a staff-vote',
- ... 'ended_at': '2019-04-26T15:12:22.123587Z'
+ ... 'ended_at': '2019-04-26T15:12:22.123587Z',
+ ... 'entries': [
+ ... {
+ ... 'actor': 336843820513755157,
+ ... 'reason': 'They know how to explain difficult concepts',
+ ... 'inserted_at': '2019-04-25T14:02:37.775587Z'
+ ... }
+ ... ],
+ ... 'reviewed': true
... }
... ]
@@ -59,12 +64,18 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
>>> {
... 'id': 1,
... 'active': true,
- ... 'actor': 336843820513755157,
- ... 'reason': 'They know how to explain difficult concepts',
... 'user': 336843820513755157,
... 'inserted_at': '2019-04-25T14:02:37.775587Z',
... 'end_reason': 'They were helpered after a staff-vote',
- ... 'ended_at': '2019-04-26T15:12:22.123587Z'
+ ... 'ended_at': '2019-04-26T15:12:22.123587Z',
+ ... 'entries': [
+ ... {
+ ... 'actor': 336843820513755157,
+ ... 'reason': 'They know how to explain difficult concepts',
+ ... 'inserted_at': '2019-04-25T14:02:37.775587Z'
+ ... }
+ ... ],
+ ... 'reviewed': false
... }
### Status codes
@@ -75,8 +86,9 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
Create a new, active nomination returns the created nominations.
The `user`, `reason` and `actor` fields are required and the `user`
and `actor` need to know by the site. Providing other valid fields
- is not allowed and invalid fields are ignored. A `user` is only
- allowed one active nomination at a time.
+ is not allowed and invalid fields are ignored. If `user` already has an
+ active nomination, a new nomination entry will be created and assigned to the
+ active nomination.
#### Request body
>>> {
@@ -91,7 +103,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
#### Status codes
- 201: returned on success
- 400: returned on failure for one of the following reasons:
- - A user already has an active nomination;
- The `user` or `actor` are unknown to the site;
- The request contained a field that cannot be set at creation.
@@ -102,16 +113,18 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
1. Updating the `reason` of `active` nomination;
2. Ending an `active` nomination;
3. Updating the `end_reason` or `reason` field of an `inactive` nomination.
+ 4. Updating `reviewed` field of `active` nomination.
While the response format and status codes are the same for all three operations (see
below), the request bodies vary depending on the operation. For all operations it holds
that providing other valid fields is not allowed and invalid fields are ignored.
- ### 1. Updating the `reason` of `active` nomination
+ ### 1. Updating the `reason` of `active` nomination. The `actor` field is required.
#### Request body
>>> {
... 'reason': 'He would make a great helper',
+ ... 'actor': 409107086526644234
... }
#### Response format
@@ -133,24 +146,35 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
See operation 1 for the response format and status codes.
### 3. Updating the `end_reason` or `reason` field of an `inactive` nomination.
+ Actor field is required when updating reason.
#### Request body
>>> {
... 'reason': 'Updated reason for this nomination',
+ ... 'actor': 409107086526644234,
... 'end_reason': 'Updated end_reason for this nomination',
... }
Note: The request body may contain either or both fields.
See operation 1 for the response format and status codes.
+
+ ### 4. Setting nomination `reviewed`
+
+ #### Request body
+ >>> {
+ ... 'reviewed': True
+ ... }
+
+ See operation 1 for the response format and status codes.
"""
serializer_class = NominationSerializer
queryset = Nomination.objects.all()
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
- filter_fields = ('user__id', 'actor__id', 'active')
- frozen_fields = ('id', 'actor', 'inserted_at', 'user', 'ended_at')
- frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at')
+ filter_fields = ('user__id', 'active')
+ frozen_fields = ('id', 'inserted_at', 'user', 'ended_at')
+ frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at', 'reviewed')
def create(self, request: HttpRequest, *args, **kwargs) -> Response:
"""
@@ -163,19 +187,50 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
raise ValidationError({field: ['This field cannot be set at creation.']})
user_id = request.data.get("user")
- if Nomination.objects.filter(active=True, user__id=user_id).exists():
- raise ValidationError({'active': ['There can only be one active nomination.']})
+ nomination_filter = Nomination.objects.filter(active=True, user__id=user_id)
+
+ if not nomination_filter.exists():
+ serializer = NominationSerializer(
+ data=ChainMap(
+ request.data,
+ {"active": True}
+ )
+ )
+ serializer.is_valid(raise_exception=True)
+ nomination = Nomination.objects.create(**serializer.validated_data)
- serializer = self.get_serializer(
- data=ChainMap(
- request.data,
- {"active": True}
+ # The serializer will truncate and get rid of excessive data
+ entry_serializer = NominationEntrySerializer(
+ data=ChainMap(request.data, {"nomination": nomination.id})
)
+ entry_serializer.is_valid(raise_exception=True)
+ NominationEntry.objects.create(**entry_serializer.validated_data)
+
+ data = NominationSerializer(nomination).data
+
+ headers = self.get_success_headers(data)
+ return Response(data, status=status.HTTP_201_CREATED, headers=headers)
+
+ entry_serializer = NominationEntrySerializer(
+ data=ChainMap(request.data, {"nomination": nomination_filter[0].id})
)
- serializer.is_valid(raise_exception=True)
- self.perform_create(serializer)
- headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+ entry_serializer.is_valid(raise_exception=True)
+
+ # Don't allow a user to create many nomination entries in a single nomination
+ if NominationEntry.objects.filter(
+ nomination_id=nomination_filter[0].id,
+ actor__id=entry_serializer.validated_data["actor"].id
+ ).exists():
+ raise ValidationError(
+ {'actor': ['This actor has already endorsed this nomination.']}
+ )
+
+ NominationEntry.objects.create(**entry_serializer.validated_data)
+
+ data = NominationSerializer(nomination_filter[0]).data
+
+ headers = self.get_success_headers(data)
+ return Response(data, status=status.HTTP_201_CREATED, headers=headers)
def partial_update(self, request: HttpRequest, *args, **kwargs) -> Response:
"""
@@ -203,7 +258,7 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
elif instance.active and not data['active']:
# 2. We're ending an active nomination.
- if 'reason' in data:
+ if 'reason' in request.data:
raise ValidationError(
{'reason': ['This field cannot be set when ending a nomination.']}
)
@@ -213,6 +268,11 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'end_reason': ['This field is required when ending a nomination.']}
)
+ if 'reviewed' in request.data:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set while you are ending a nomination.']}
+ )
+
instance.ended_at = timezone.now()
elif 'active' in data:
@@ -221,6 +281,34 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'active': ['This field can only be used to end a nomination']}
)
+ # This is actually covered, but for some reason coverage don't think so.
+ elif 'reviewed' in request.data: # pragma: no cover
+ # 4. We are altering the reviewed state of the nomination.
+ if not instance.active:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set if the nomination is inactive.']}
+ )
+
+ if 'reason' in request.data:
+ if 'actor' not in request.data:
+ raise ValidationError(
+ {'actor': ['This field is required when editing the reason.']}
+ )
+
+ entry_filter = NominationEntry.objects.filter(
+ nomination_id=instance.id,
+ actor__id=request.data['actor']
+ )
+
+ if not entry_filter.exists():
+ raise ValidationError(
+ {'actor': ["The actor doesn't exist or has not nominated the user."]}
+ )
+
+ entry = entry_filter[0]
+ entry.reason = request.data['reason']
+ entry.save()
+
serializer.save()
return Response(serializer.data)
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
index 829e2694..25722f5a 100644
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -119,6 +119,22 @@ class UserViewSet(ModelViewSet):
- 200: returned on success
- 404: if a user with the given `snowflake` could not be found
+ ### GET /bot/users/<snowflake:int>/metricity_review_data
+ Gets metricity data for a single user's review by ID.
+
+ #### Response format
+ >>> {
+ ... 'joined_at': '2020-08-26T08:09:43.507000',
+ ... 'top_channel_activity': [['off-topic', 15],
+ ... ['talent-pool', 4],
+ ... ['defcon', 2]],
+ ... 'total_messages': 22
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if a user with the given `snowflake` could not be found
+
### POST /bot/users
Adds a single or multiple new users.
The roles attached to the user(s) must be roles known by the site.
@@ -262,3 +278,18 @@ class UserViewSet(ModelViewSet):
except NotFound:
return Response(dict(detail="User not found in metricity"),
status=status.HTTP_404_NOT_FOUND)
+
+ @action(detail=True)
+ def metricity_review_data(self, request: Request, pk: str = None) -> Response:
+ """Request handler for metricity_review_data endpoint."""
+ user = self.get_object()
+
+ with Metricity() as metricity:
+ try:
+ data = metricity.user(user.id)
+ data["total_messages"] = metricity.total_messages(user.id)
+ data["top_channel_activity"] = metricity.top_channel_activity(user.id)
+ return Response(data, status=status.HTTP_200_OK)
+ except NotFound:
+ return Response(dict(detail="User not found in metricity"),
+ status=status.HTTP_404_NOT_FOUND)