Skip to content

Commit 27c910d

Browse files
authored
Merge pull request #68 from mapswipe/feature/assest-validations
2 parents 832e743 + cc25d41 commit 27c910d

File tree

27 files changed

+753
-422
lines changed

27 files changed

+753
-422
lines changed

apps/common/models.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ class UserResource(Model):
3232
)
3333
created_at = models.DateTimeField(auto_now_add=True)
3434
modified_at = models.DateTimeField(auto_now=True)
35-
created_by = models.ForeignKey(
35+
created_by: User = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride]
3636
User,
3737
related_name="%(class)s_created",
3838
on_delete=models.PROTECT,
3939
)
40-
modified_by = models.ForeignKey(
40+
modified_by: User = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride]
4141
User,
4242
related_name="%(class)s_modified",
4343
on_delete=models.PROTECT,
@@ -60,7 +60,7 @@ def __str__(self):
6060
class ArchivableResource(Model):
6161
is_archived = models.BooleanField(default=False)
6262
archived_at = models.DateTimeField(null=True, blank=True)
63-
archived_by = models.ForeignKey(
63+
archived_by: User = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride]
6464
User,
6565
related_name="+",
6666
on_delete=models.SET_NULL,
@@ -161,3 +161,75 @@ def update_firebase_push_status(
161161

162162
class Meta(TypedModelMeta): # type: ignore[reportIncompatibleVariableOverride]
163163
abstract = True
164+
165+
166+
class AssetMimetypeEnum(models.IntegerChoices):
167+
GEOJSON = 100, "application/geo+json"
168+
169+
IMAGE_JPEG = 201, "image/jpeg"
170+
IMAGE_PNG = 202, "image/png"
171+
IMAGE_GIF = 203, "image/gif"
172+
173+
@classmethod
174+
def get_display(cls, value: typing.Self | int) -> str:
175+
if value in cls:
176+
return str(cls(value).label)
177+
return "Unknown"
178+
179+
@classmethod
180+
def is_valid_mimetype(cls, mimetype: str) -> bool:
181+
"""
182+
Check if the given mimetype is valid for project assets.
183+
"""
184+
return mimetype in [choice.label for choice in cls]
185+
186+
@classmethod
187+
def get_mimetype_by_label(cls, label: str) -> typing.Self | None:
188+
for choice in cls:
189+
if choice.label == label:
190+
return choice
191+
return None
192+
193+
194+
# FIXME(tnagorra): Finalize the enum labels
195+
class AssetTypeEnum(models.IntegerChoices):
196+
INPUT = 100, "Input"
197+
OUTPUT = 200, "Output"
198+
STATS = 300, "Stats"
199+
200+
@classmethod
201+
def get_display(cls, value: typing.Self | int) -> str:
202+
if value in cls:
203+
return str(cls(value).label)
204+
return "Unknown"
205+
206+
207+
class CommonAsset(Model):
208+
Mimetype = AssetMimetypeEnum
209+
MAX_FILE_SIZE: int = 10 * 1024 * 1024 # MB
210+
Type = AssetTypeEnum
211+
212+
type = IntegerChoicesField(
213+
choices_enum=AssetTypeEnum,
214+
)
215+
216+
mimetype = IntegerChoicesField(
217+
choices_enum=AssetMimetypeEnum,
218+
)
219+
220+
file_size = models.PositiveIntegerField(
221+
help_text=gettext_lazy("The size of the file in bytes"),
222+
)
223+
224+
marked_as_deleted = models.BooleanField(
225+
default=False,
226+
help_text=gettext_lazy("If this flag is enabled, this project asset will be deleted in the future"),
227+
)
228+
229+
class Meta(TypedModelMeta): # type: ignore[reportIncompatibleVariableOverride]
230+
abstract = True
231+
232+
@classmethod
233+
def usable_objects(cls):
234+
"""Returns objects that are mot marked for deletion"""
235+
return cls.objects.filter(marked_as_deleted=False)

apps/common/serializers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import logging
2+
import mimetypes
23
import typing
34

45
from django.core.exceptions import ValidationError
56
from django.http.request import HttpRequest
7+
from django.template.defaultfilters import filesizeformat
68
from django.utils import timezone
79
from django.utils.translation import gettext
810
from firebase_admin import auth
911
from rest_framework import serializers
1012

1113
from apps.common.models import ArchivableResource, UserResource
14+
from apps.project.models import CommonAsset
1215
from apps.user.models import User
1316
from utils.common import validate_ulid
1417

@@ -17,6 +20,10 @@
1720
logger = logging.getLogger(__name__)
1821

1922

23+
if typing.TYPE_CHECKING:
24+
from django.core.files.base import ContentFile
25+
26+
2027
class DrfContextType(typing.TypedDict):
2128
request: HttpRequest
2229

@@ -160,3 +167,46 @@ def validate(self, attrs: dict[str, typing.Any]):
160167
class FirebaseAuthResponseSerializer(serializers.Serializer[typing.Any]):
161168
ok = serializers.BooleanField(required=True)
162169
error = serializers.CharField()
170+
171+
172+
class CommonAssetSerializer(serializers.ModelSerializer[CommonAsset]):
173+
def _validate_file(self, attrs: dict[str, typing.Any]) -> None:
174+
file_content: ContentFile[bytes] = attrs["file"]
175+
file_size = file_content.size
176+
max_file_size = CommonAsset.MAX_FILE_SIZE
177+
178+
if file_size > max_file_size:
179+
raise serializers.ValidationError(
180+
gettext("Filesize should be less than: %s. Current is: %s")
181+
% (
182+
filesizeformat(max_file_size),
183+
filesizeformat(file_size),
184+
),
185+
)
186+
187+
# https://docs.python.org/3/library/mimetypes.html#mimetypes.guess_type
188+
mimetype, *_ = mimetypes.guess_type(file_content.name) # type: ignore[reportUnknownArgumentType]
189+
190+
# TODO(susilnem): Use library like filemagic to determine mimetype instead?
191+
if mimetype is None:
192+
raise serializers.ValidationError(
193+
gettext("Could not determine mimetype of the file: %s") % file_content.name,
194+
)
195+
196+
if not CommonAsset.Mimetype.is_valid_mimetype(mimetype):
197+
raise serializers.ValidationError(
198+
gettext("File mimetype is not supported: %s") % mimetype,
199+
)
200+
201+
if "mimetype" in attrs and CommonAsset.Mimetype.get_mimetype_by_label(mimetype) != attrs["mimetype"]:
202+
raise serializers.ValidationError(
203+
gettext("File mimetype does not match the provided mimetype: %s") % mimetype,
204+
)
205+
206+
attrs["mimetype"] = CommonAsset.Mimetype.get_mimetype_by_label(mimetype)
207+
attrs["file_size"] = file_size
208+
209+
@typing.override
210+
def validate(self, attrs: dict[str, typing.Any]) -> dict[str, typing.Any]:
211+
self._validate_file(attrs)
212+
return super().validate(attrs)

apps/community_dashboard/models.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ def __str__(self):
3232

3333
class AggregatedUserStatData(Model):
3434
# Ref Fields
35-
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+")
36-
user = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+")
35+
project: Project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride]
36+
user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride]
3737
timestamp_date = models.DateField()
3838
# Aggregated Fields
3939
total_time = models.IntegerField() # seconds
@@ -59,9 +59,9 @@ def __str__(self):
5959

6060
class AggregatedUserGroupStatData(Model):
6161
# Ref Fields
62-
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+")
63-
user = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+")
64-
user_group = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE, related_name="+")
62+
project: Project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride]
63+
user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride]
64+
user_group: ContributorUserGroup = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE, related_name="+") # type: ignore[reportIncompatibleVariableOverride]
6565
timestamp_date = models.DateField()
6666
# Aggregated Fields
6767
total_time = models.IntegerField() # seconds

apps/contributor/models.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class ContributorUser(models.Model):
1919
unique=True,
2020
help_text="Firebase User ID",
2121
)
22-
team = models.ForeignKey(
22+
team: "ContributorTeam | None" = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride]
2323
"ContributorTeam",
2424
on_delete=models.SET_NULL,
2525
null=True,
@@ -46,8 +46,8 @@ def __str__(self):
4646

4747

4848
class ContributorUserGroupMembership(models.Model):
49-
user_group = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE)
50-
user = models.ForeignKey(ContributorUser, on_delete=models.CASCADE)
49+
user_group: ContributorUserGroup = models.ForeignKey(ContributorUserGroup, on_delete=models.CASCADE) # type: ignore[reportIncompatibleVariableOverride]
50+
user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.CASCADE) # type: ignore[reportIncompatibleVariableOverride]
5151
is_active = models.BooleanField()
5252

5353
# Type hints
@@ -67,7 +67,7 @@ class ContributorUserGroupMembershipLogActionEnum(models.IntegerChoices):
6767
class ContributorUserGroupMembershipLog(models.Model):
6868
ACTION = ContributorUserGroupMembershipLogActionEnum
6969

70-
membership = models.ForeignKey(ContributorUserGroupMembership, on_delete=models.CASCADE)
70+
membership: ContributorUserGroupMembership = models.ForeignKey(ContributorUserGroupMembership, on_delete=models.CASCADE) # type: ignore[reportIncompatibleVariableOverride]
7171
# Sync with firebase
7272
action = IntegerChoicesField(choices_enum=ContributorUserGroupMembershipLogActionEnum)
7373
date = models.DateTimeField()

apps/mapping/models.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ class MappingSession(models.Model):
2828
# FIXME(tnagorra): We might need to skip the indexing
2929
old_id = models.CharField(max_length=30, db_index=True, null=True)
3030

31-
project_task_group = models.ForeignKey(ProjectTaskGroup, on_delete=models.PROTECT)
32-
contributor_user = models.ForeignKey(ContributorUser, on_delete=models.PROTECT)
31+
project_task_group: ProjectTaskGroup = models.ForeignKey(ProjectTaskGroup, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride]
32+
contributor_user: ContributorUser = models.ForeignKey(ContributorUser, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride]
3333

3434
app_version = models.CharField(max_length=10)
3535
client_type = IntegerChoicesField(choices_enum=MappingSessionClientTypeEnum)
@@ -54,8 +54,8 @@ class MappingSessionUserGroup(models.Model):
5454
# FIXME(tnagorra): We might need to skip the indexing
5555
old_id = models.CharField(max_length=30, db_index=True, null=True)
5656

57-
mapping_session = models.ForeignKey(MappingSession, on_delete=models.PROTECT)
58-
user_group = models.ForeignKey(
57+
mapping_session: MappingSession = models.ForeignKey(MappingSession, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride]
58+
user_group: ContributorUserGroup = models.ForeignKey( # type: ignore[reportIncompatibleVariableOverride]
5959
ContributorUserGroup,
6060
on_delete=models.PROTECT,
6161
related_name="+",
@@ -73,8 +73,8 @@ class MappingSessionResult(models.Model):
7373
# FIXME(tnagorra): We might need to skip the indexing
7474
old_id = models.CharField(max_length=30, db_index=True, null=True)
7575

76-
session = models.ForeignKey(MappingSession, on_delete=models.PROTECT)
77-
project_task = models.ForeignKey(ProjectTask, on_delete=models.PROTECT)
76+
session: MappingSession = models.ForeignKey(MappingSession, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride]
77+
project_task: ProjectTaskGroup = models.ForeignKey(ProjectTask, on_delete=models.PROTECT) # type: ignore[reportIncompatibleVariableOverride]
7878
result = models.PositiveSmallIntegerField()
7979

8080
# TODO(thenav56): Add constraint to make sure we have non-duplicate row with task_id, .session.user_id

apps/project/migrations/0001_initial.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ class Migration(migrations.Migration):
7676
('client_id', models.CharField(editable=False, max_length=26, unique=True, validators=[apps.common.models.validate_ulid])),
7777
('created_at', models.DateTimeField(auto_now_add=True)),
7878
('modified_at', models.DateTimeField(auto_now=True)),
79-
('type', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'Input'), (200, 'Output'), (300, 'Stats')], choices_enum=apps.project.models.ProjectAssetTypeEnum)),
80-
('mimetype', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'application/geo+json'), (201, 'image/jpeg'), (202, 'image/png'), (203, 'image/gif')], choices_enum=apps.project.models.ProjectAssetMimetypeEnum)),
79+
('type', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'Input'), (200, 'Output'), (300, 'Stats')], choices_enum=apps.common.models.AssetTypeEnum)),
80+
('mimetype', django_choices_field.fields.IntegerChoicesField(choices=[(100, 'application/geo+json'), (201, 'image/jpeg'), (202, 'image/png'), (203, 'image/gif')], choices_enum=apps.common.models.AssetMimetypeEnum)),
8181
('file', models.FileField(help_text='The file associated with the asset', upload_to=apps.project.models.UploadHelper.project_asset)),
8282
('marked_as_deleted', models.BooleanField(default=False, help_text='If this flag is enabled, this project asset will be deleted in the future')),
8383
],
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 5.2.4 on 2025-07-29 04:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('project', '0010_merge_20250728_1145'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='projectasset',
15+
name='file_size',
16+
field=models.PositiveIntegerField(default=1, help_text='The size of the file in bytes'),
17+
preserve_default=False,
18+
),
19+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.1.6 on 2025-07-30 13:23
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('project', '0011_project_canonical_id'),
10+
('project', '0011_projectasset_file_size'),
11+
]
12+
13+
operations = [
14+
]

apps/project/models.py

Lines changed: 5 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,41 +10,14 @@
1010
from django.utils.translation import gettext_lazy
1111
from django_choices_field import IntegerChoicesField
1212

13-
from apps.common.models import ArchivableResource, FirebasePushStatusEnum, FirebaseResource, UserResource
13+
from apps.common.models import ArchivableResource, CommonAsset, FirebasePushStatusEnum, FirebaseResource, UserResource
1414
from apps.contributor.models import ContributorTeam
1515
from utils.fields import validate_percentage
1616

1717
if typing.TYPE_CHECKING:
1818
from apps.tutorial.models import Tutorial
1919

2020

21-
class ProjectAssetMimetypeEnum(models.IntegerChoices):
22-
GEOJSON = 100, "application/geo+json"
23-
24-
IMAGE_JPEG = 201, "image/jpeg"
25-
IMAGE_PNG = 202, "image/png"
26-
IMAGE_GIF = 203, "image/gif"
27-
28-
@classmethod
29-
def get_display(cls, value: typing.Self | int) -> str:
30-
if value in cls:
31-
return str(cls(value).label)
32-
return "Unknown"
33-
34-
35-
# FIXME(tnagorra): Finalize the enum labels
36-
class ProjectAssetTypeEnum(models.IntegerChoices):
37-
INPUT = 100, "Input"
38-
OUTPUT = 200, "Output"
39-
STATS = 300, "Stats"
40-
41-
@classmethod
42-
def get_display(cls, value: typing.Self | int) -> str:
43-
if value in cls:
44-
return str(cls(value).label)
45-
return "Unknown"
46-
47-
4821
class ProjectTypeEnum(models.IntegerChoices):
4922
FIND = 1, "Find"
5023
""" Find project type. Previously known as Classification / Build Area. """
@@ -407,39 +380,18 @@ def clean(self):
407380
# self.requiredResults += group.requiredCount * group.numberOfTasks
408381

409382

410-
class ProjectAsset(UserResource):
411-
Type = ProjectAssetTypeEnum
412-
Mimetype = ProjectAssetMimetypeEnum
413-
414-
type = IntegerChoicesField(
415-
choices_enum=ProjectAssetTypeEnum,
416-
)
417-
418-
mimetype = IntegerChoicesField(
419-
choices_enum=ProjectAssetMimetypeEnum,
420-
)
421-
422-
file = models.FileField(
423-
upload_to=UploadHelper.project_asset,
424-
help_text=gettext_lazy("The file associated with the asset"),
425-
)
426-
383+
class ProjectAsset(UserResource, CommonAsset): # type: ignore[reportIncompatibleVariableOverride]
427384
project: Project = models.ForeignKey( # type: ignore[reportAssignmentType]
428385
Project,
429386
on_delete=models.CASCADE,
430387
related_name="+",
431388
)
432389

433-
marked_as_deleted = models.BooleanField(
434-
default=False,
435-
help_text=gettext_lazy("If this flag is enabled, this project asset will be deleted in the future"),
390+
file = models.FileField(
391+
upload_to=UploadHelper.project_asset,
392+
help_text=gettext_lazy("The file associated with the asset"),
436393
)
437394

438-
@classmethod
439-
def usable_objects(cls):
440-
"""Returns objects that are mot marked for deletion"""
441-
return cls.objects.filter(marked_as_deleted=False)
442-
443395
# Type hints
444396
project_id: int
445397

0 commit comments

Comments
 (0)