Coverage report: + 73% +
+ + ++ Files + Functions + Classes +
++ coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+diff --git a/backend/htmlcov/.gitignore b/backend/htmlcov/.gitignore new file mode 100644 index 0000000..ccccf14 --- /dev/null +++ b/backend/htmlcov/.gitignore @@ -0,0 +1,2 @@ +# Created by coverage.py +* diff --git a/backend/htmlcov/class_index.html b/backend/htmlcov/class_index.html new file mode 100644 index 0000000..4b659b5 --- /dev/null +++ b/backend/htmlcov/class_index.html @@ -0,0 +1,1187 @@ + + +
+ ++ coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
++ No items found using the specified filter. +
++ coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
++ No items found using the specified filter. +
++ coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+| File | +statements | +missing | +excluded | +coverage | +
|---|---|---|---|---|
| comments\__init__.py | +0 | +0 | +0 | +100% | +
| comments\admin.py | +3 | +0 | +0 | +100% | +
| comments\apps.py | +3 | +0 | +0 | +100% | +
| comments\migrations\0001_initial.py | +7 | +0 | +0 | +100% | +
| comments\migrations\__init__.py | +0 | +0 | +0 | +100% | +
| comments\models.py | +19 | +0 | +0 | +100% | +
| comments\permissions.py | +4 | +1 | +0 | +75% | +
| comments\serializers.py | +16 | +0 | +0 | +100% | +
| comments\urls.py | +5 | +0 | +0 | +100% | +
| comments\views.py | +59 | +20 | +0 | +66% | +
| manage.py | +11 | +2 | +0 | +82% | +
| secfit\__init__.py | +0 | +0 | +0 | +100% | +
| secfit\asgi.py | +4 | +4 | +0 | +0% | +
| secfit\settings.py | +30 | +0 | +0 | +100% | +
| secfit\urls.py | +7 | +0 | +0 | +100% | +
| secfit\wsgi.py | +4 | +4 | +0 | +0% | +
| tests\__init__.py | +0 | +0 | +0 | +100% | +
| tests\test_TC001.py | +26 | +0 | +0 | +100% | +
| tests\test_TC002.py | +43 | +0 | +0 | +100% | +
| tests\test_TC003.py | +22 | +0 | +0 | +100% | +
| tests\test_TC004.py | +25 | +0 | +0 | +100% | +
| tests\test_TC005.py | +28 | +0 | +0 | +100% | +
| users\__init__.py | +0 | +0 | +0 | +100% | +
| users\admin.py | +14 | +0 | +0 | +100% | +
| users\apps.py | +3 | +0 | +0 | +100% | +
| users\auth_backend.py | +15 | +15 | +0 | +0% | +
| users\forms.py | +11 | +0 | +0 | +100% | +
| users\migrations\0001_initial.py | +11 | +0 | +0 | +100% | +
| users\migrations\0002_user_iscoach.py | +4 | +0 | +0 | +100% | +
| users\migrations\0003_user_specialism.py | +4 | +0 | +0 | +100% | +
| users\migrations\__init__.py | +0 | +0 | +0 | +100% | +
| users\models.py | +23 | +0 | +0 | +100% | +
| users\permissions.py | +36 | +15 | +0 | +58% | +
| users\serializers.py | +57 | +24 | +0 | +58% | +
| users\urls.py | +4 | +0 | +0 | +100% | +
| users\validators.py | +43 | +17 | +0 | +60% | +
| users\views.py | +145 | +64 | +0 | +56% | +
| workouts\__init__.py | +0 | +0 | +0 | +100% | +
| workouts\admin.py | +6 | +0 | +0 | +100% | +
| workouts\apps.py | +3 | +0 | +0 | +100% | +
| workouts\migrations\0001_initial.py | +8 | +0 | +0 | +100% | +
| workouts\migrations\__init__.py | +0 | +0 | +0 | +100% | +
| workouts\mixins.py | +5 | +1 | +0 | +80% | +
| workouts\models.py | +40 | +5 | +0 | +88% | +
| workouts\parsers.py | +21 | +17 | +0 | +19% | +
| workouts\permissions.py | +32 | +15 | +0 | +53% | +
| workouts\serializers.py | +75 | +35 | +0 | +53% | +
| workouts\urls.py | +4 | +0 | +0 | +100% | +
| workouts\views.py | +114 | +29 | +0 | +75% | +
| Total | +994 | +268 | +0 | +73% | +
+ No items found using the specified filter. +
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1#!/usr/bin/env python
+2"""Django's command-line utility for administrative tasks."""
+3import os
+4import sys
+ + +7def main():
+8 """Run administrative tasks."""
+9 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secfit.settings')
+10 try:
+11 from django.core.management import execute_from_command_line
+12 except ImportError as exc:
+13 raise ImportError(
+14 "Couldn't import Django. Are you sure it's installed and "
+15 "available on your PYTHONPATH environment variable? Did you "
+16 "forget to activate a virtual environment?"
+17 ) from exc
+18 execute_from_command_line(sys.argv)
+ + +21if __name__ == '__main__':
+22 main()
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# Generated by Django 4.0.8 on 2024-08-15 08:05
+ +3from django.conf import settings
+4from django.db import migrations, models
+5import django.db.models.deletion
+ + +8class Migration(migrations.Migration):
+ +10 initial = True
+ +12 dependencies = [
+13 ('workouts', '0001_initial'),
+14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+15 ]
+ +17 operations = [
+18 migrations.CreateModel(
+19 name='Comment',
+20 fields=[
+21 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+22 ('content', models.TextField()),
+23 ('timestamp', models.DateTimeField(auto_now_add=True)),
+24 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
+25 ('workout', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='workouts.workout')),
+26 ],
+27 options={
+28 'ordering': ['-timestamp'],
+29 },
+30 ),
+31 migrations.CreateModel(
+32 name='Like',
+33 fields=[
+34 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+35 ('timestamp', models.DateTimeField(auto_now_add=True)),
+36 ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='comments.comment')),
+37 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL)),
+38 ],
+39 ),
+40 ]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.contrib import admin
+ +3from .models import Comment
+ +5admin.site.register(Comment)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.apps import AppConfig
+ + +4class CommentsConfig(AppConfig):
+5 name = "comments"
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.db import models
+ + +4from django.conf import settings
+5from django.contrib.contenttypes.fields import GenericForeignKey
+6from django.contrib.contenttypes.models import ContentType
+7from django.urls import reverse
+8from django.db import models
+9from django.contrib.auth import get_user_model
+10from workouts.models import Workout
+ +12class Comment(models.Model):
+13 """Django model for a comment left on a workout.
+ +15 Attributes:
+16 owner: Who posted the comment
+17 workout: The workout this comment was left on.
+18 content: The content of the comment.
+19 timestamp: When the comment was created.
+20 """
+21 owner = models.ForeignKey(
+22 get_user_model(), on_delete=models.CASCADE, related_name="comments"
+23 )
+24 workout = models.ForeignKey(
+25 Workout, on_delete=models.CASCADE, related_name="comments"
+26 )
+27 content = models.TextField()
+28 timestamp = models.DateTimeField(auto_now_add=True)
+ +30 class Meta:
+31 ordering = ["-timestamp"]
+ + +34class Like(models.Model):
+35 """Django model for a reaction to a comment.
+ + +38 Attributes:
+39 owner: Who liked the comment
+40 comment: The comment that was liked
+41 timestamp: When the like occurred.
+42 """
+43 owner = models.ForeignKey(
+44 get_user_model(), on_delete=models.CASCADE, related_name="likes"
+45 )
+46 comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="likes")
+47 timestamp = models.DateTimeField(auto_now_add=True)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework import permissions
+ + +4class IsCommentVisibleToUser(permissions.BasePermission):
+5 """
+6 Custom permission to only allow a comment to be viewed
+7 if one of the following holds:
+8 - The comment is on a public visibility workout
+9 - The comment was written by the user
+10 - The comment is on a coach visibility workout and the user is the workout owner's coach
+11 - The comment is on a workout owned by the user
+12 """
+ +14 def has_object_permission(self, request, view, obj):
+15 # Write permissions are only allowed to the owner.
+16 return (
+17 obj.workout.visibility == "PU"
+18 or obj.owner == request.user
+19 or (obj.workout.visibility == "CO" and obj.owner.coach == request.user)
+20 or obj.workout.owner == request.user
+21 )
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework import serializers
+2from rest_framework.serializers import HyperlinkedRelatedField
+3from .models import Comment, Like
+4from workouts.models import Workout
+ + +7class CommentSerializer(serializers.HyperlinkedModelSerializer):
+8 owner = serializers.ReadOnlyField(source="owner.username")
+9 workout = HyperlinkedRelatedField(
+10 queryset=Workout.objects.all(), view_name="workout-detail"
+11 )
+ +13 class Meta:
+14 model = Comment
+15 fields = ["url", "id", "owner", "workout", "content", "timestamp"]
+ + +18class LikeSerializer(serializers.HyperlinkedModelSerializer):
+19 owner = serializers.ReadOnlyField(source="owner.username")
+20 comment = HyperlinkedRelatedField(
+21 queryset=Comment.objects.all(), view_name="comment-detail"
+22 )
+ +24 class Meta:
+25 model = Like
+26 fields = ["url", "id", "owner", "comment", "timestamp"]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.urls import path, include
+2from .models import Comment, Like
+3from .views import CommentList, CommentDetail, LikeList, LikeDetail
+4from rest_framework.urlpatterns import format_suffix_patterns
+ +6urlpatterns = [
+7 path("api/comments/", CommentList.as_view(), name="comment-list"),
+8 path("api/comments/<int:pk>/", CommentDetail.as_view(), name="comment-detail"),
+9 path("api/likes/", LikeList.as_view(), name="like-list"),
+10 path("api/likes/<int:pk>/", LikeDetail.as_view(), name="like-detail"),
+11]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.shortcuts import render
+2from rest_framework import generics, mixins
+3from .models import Comment, Like
+4from rest_framework import permissions
+5from .permissions import IsCommentVisibleToUser
+6from workouts.permissions import IsOwner, IsReadOnly
+7from .serializers import CommentSerializer, LikeSerializer
+8from django.db.models import Q
+9from rest_framework.filters import OrderingFilter
+ +11class CommentList(
+12 mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView
+13):
+14 serializer_class = CommentSerializer
+15 permission_classes = [permissions.IsAuthenticated]
+16 filter_backends = [OrderingFilter]
+17 ordering_fields = ["timestamp"]
+ +19 def get(self, request, *args, **kwargs):
+20 return self.list(request, *args, **kwargs)
+ +22 def post(self, request, *args, **kwargs):
+23 return self.create(request, *args, **kwargs)
+ +25 def perform_create(self, serializer):
+26 serializer.save(owner=self.request.user)
+ +28 def get_queryset(self):
+29 workout_pk = self.kwargs.get("pk")
+30 qs = Comment.objects.none()
+ +32 if workout_pk:
+33 qs = Comment.objects.filter(workout=workout_pk)
+34 elif self.request.user:
+35 """A comment should be visible to the requesting user if any of the following hold:
+36 - The comment is on a public visibility workout
+37 - The comment was written by the user
+38 - The comment is on a coach visibility workout and the user is the workout owner's coach
+39 - The comment is on a workout owned by the user
+40 """
+41 qs = Comment.objects.filter(
+42 Q(workout__visibility="PU")
+43 | Q(owner=self.request.user)
+44 | (
+45 Q(workout__visibility="CO")
+46 & Q(workout__owner__coach=self.request.user)
+47 )
+48 | Q(workout__owner=self.request.user)
+49 ).distinct()
+ +51 return qs
+ + +54class CommentDetail(
+55 mixins.RetrieveModelMixin,
+56 mixins.UpdateModelMixin,
+57 mixins.DestroyModelMixin,
+58 generics.GenericAPIView,
+59):
+60 queryset = Comment.objects.all()
+61 serializer_class = CommentSerializer
+62 permission_classes = [
+63 permissions.IsAuthenticated & IsCommentVisibleToUser & (IsOwner | IsReadOnly)
+64 ]
+ +66 def get(self, request, *args, **kwargs):
+67 return self.retrieve(request, *args, **kwargs)
+ +69 def put(self, request, *args, **kwargs):
+70 return self.update(request, *args, **kwargs)
+ +72 def delete(self, request, *args, **kwargs):
+73 return self.destroy(request, *args, **kwargs)
+ + +76class LikeList(mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView):
+77 serializer_class = LikeSerializer
+78 permission_classes = [permissions.IsAuthenticated]
+ +80 def get(self, request, *args, **kwargs):
+81 return self.list(request, *args, **kwargs)
+ +83 def post(self, request, *args, **kwargs):
+84 return self.create(request, *args, **kwargs)
+ +86 def perform_create(self, serializer):
+87 serializer.save(owner=self.request.user)
+ +89 def get_queryset(self):
+90 return Like.objects.filter(owner=self.request.user)
+ + +93class LikeDetail(
+94 mixins.RetrieveModelMixin,
+95 mixins.UpdateModelMixin,
+96 mixins.DestroyModelMixin,
+97 generics.GenericAPIView,
+98):
+99 queryset = Like.objects.all()
+100 serializer_class = LikeSerializer
+101 permission_classes = [permissions.IsAuthenticated]
+ +103 def get(self, request, *args, **kwargs):
+104 return self.retrieve(request, *args, **kwargs)
+ +106 def put(self, request, *args, **kwargs):
+107 return self.update(request, *args, **kwargs)
+ +109 def delete(self, request, *args, **kwargs):
+110 return self.destroy(request, *args, **kwargs)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""Module for registering models from workouts app to admin page so that they appear
+2"""
+3from django.contrib import admin
+ +5from .models import Exercise, ExerciseInstance, Workout, WorkoutFile
+ +7admin.site.register(Exercise)
+8admin.site.register(ExerciseInstance)
+9admin.site.register(Workout)
+10admin.site.register(WorkoutFile)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""AppConfig for workouts app
+2"""
+3from django.apps import AppConfig
+ +5class WorkoutsConfig(AppConfig):
+6 """AppConfig for workouts app
+ +8 Attributes:
+9 name (str): The name of the application
+10 """
+ +12 name = "workouts"
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""
+2Mixins for the workouts application
+3"""
+ +5class CreateListModelMixin(object):
+6 """Mixin that allows to create multiple objects from lists.
+7 Taken from https://stackoverflow.com/a/48885641
+8 """
+ +10 def get_serializer(self, *args, **kwargs):
+11 """If an array is passed, set serializer to many.
+ +13 kwargs["many"] will be set to true if an array is passed. This argument
+14 is passed when retrieving the serializer.
+ +16 Args:
+17 *args: Variable length argument list passed to the serializer.
+18 **kwargs: Arbitrary keyword arguments passed to the serializer, including "many".
+ +20 Returns:
+21 [type]: [description]
+22 """
+23 if isinstance(kwargs.get("data", {}), list):
+24 kwargs["many"] = True
+25 return super(CreateListModelMixin, self).get_serializer(*args, **kwargs)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""Contains the models for the workouts Django application. Users
+2log workouts (Workout), which contain instances (ExerciseInstance) of various
+3type of exercises (Exercise). The user can also upload files (WorkoutFile) .
+4"""
+5import os
+6from django.db import models
+7from django.core.files.storage import FileSystemStorage
+8from django.conf import settings
+9from django.contrib.auth import get_user_model
+ + +12class OverwriteStorage(FileSystemStorage):
+13 """Filesystem storage for overwriting files. Currently unused."""
+ +15 def get_available_name(self, name, max_length=None):
+16 """https://djangosnippets.org/snippets/976/
+17 Returns a filename that's free on the target storage system, and
+18 available for new content to be written to.
+ +20 Args:
+21 name (str): Name of the file
+22 max_length (int, optional): Maximum length of a file name. Defaults to None.
+23 """
+24 if self.exists(name):
+25 os.remove(os.path.join(settings.MEDIA_ROOT, name))
+ + +28class Workout(models.Model):
+29 """Django model for a workout that users can log.
+ +31 A workout has several attributes, and is associated with one or more exercises
+32 (instances) and, optionally, files uploaded by the user.
+ +34 Attributes:
+35 name: Name of the workout
+36 date: Date the workout was performed or is planned
+37 notes: Notes about the workout
+38 owner: User that logged the workout
+39 visibility: The visibility level of the workout: Public, Coach, or Private
+40 """
+ +42 name = models.CharField(max_length=100)
+43 date = models.DateTimeField()
+44 notes = models.TextField()
+45 owner = models.ForeignKey(
+46 get_user_model(), on_delete=models.CASCADE, related_name="workouts"
+47 )
+ +49 # Visibility levels
+50 PUBLIC = "PU" # Visible to all authenticated users
+51 COACH = "CO" # Visible only to owner and their coach
+52 PRIVATE = "PR" # Visible only to owner
+53 VISIBILITY_CHOICES = [
+54 (PUBLIC, "Public"),
+55 (COACH, "Coach"),
+56 (PRIVATE, "Private"),
+57 ] # Choices for visibility level
+ +59 visibility = models.CharField(
+60 max_length=2, choices=VISIBILITY_CHOICES, default=COACH
+61 )
+ +63 class Meta:
+64 ordering = ["-date"]
+ +66 def __str__(self):
+67 return self.name
+ + +70class Exercise(models.Model):
+71 """Django model for an exercise type that users can create.
+ +73 Each exercise instance must have an exercise type, e.g., Pushups, Crunches, or Lunges.
+ +75 Attributes:
+76 name: Name of the exercise type
+77 description: Description of the exercise type
+78 unit: Name of the unit for the exercise type (e.g., reps, seconds)
+79 """
+ +81 name = models.CharField(max_length=100)
+82 description = models.TextField()
+83 unit = models.CharField(max_length=50)
+ +85 def __str__(self):
+86 return self.name
+ + +89class ExerciseInstance(models.Model):
+90 """Django model for an instance of an exercise.
+ +92 Each workout has one or more exercise instances, each of a given type. For example,
+93 Kyle's workout on 15.06.2029 had one exercise instance: 3 (sets) reps (unit) of
+94 10 (number) pushups (exercise type)
+ +96 Attributes:
+97 workout: The workout associated with this exercise instance
+98 exercise: The exercise type of this instance
+99 sets: The number of sets the owner will perform/performed
+100 number: The number of repetitions in each set the owner will perform/performed
+101 """
+ +103 workout = models.ForeignKey(
+104 Workout, on_delete=models.CASCADE, related_name="exercise_instances"
+105 )
+106 exercise = models.ForeignKey(
+107 Exercise, on_delete=models.CASCADE, related_name="instances"
+108 )
+109 sets = models.IntegerField()
+110 number = models.IntegerField()
+ + +113def workout_directory_path(instance, filename):
+114 """Return path for which workout files should be uploaded on the web server
+ +116 Args:
+117 instance (WorkoutFile): WorkoutFile instance
+118 filename (str): Name of the file
+ +120 Returns:
+121 str: Path where workout file is stored
+122 """
+123 return f"workouts/{instance.workout.id}/{filename}"
+ + +126class WorkoutFile(models.Model):
+127 """Django model for file associated with a workout. Basically a wrapper.
+ +129 Attributes:
+130 workout: The workout for which this file has been uploaded
+131 owner: The user who uploaded the file
+132 file: The actual file that's being uploaded
+133 """
+ +135 workout = models.ForeignKey(Workout, on_delete=models.CASCADE, related_name="files")
+136 owner = models.ForeignKey(
+137 get_user_model(), on_delete=models.CASCADE, related_name="workout_files"
+138 )
+139 file = models.FileField(upload_to=workout_directory_path)
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""Contains custom parsers for serializers from the workouts Django app
+2"""
+3import json
+4from rest_framework import parsers
+ +6# Thanks to https://stackoverflow.com/a/50514630
+7class MultipartJsonParser(parsers.MultiPartParser):
+8 """Parser for serializing multipart data containing both files and JSON.
+ +10 This is currently unused.
+11 """
+ +13 def parse(self, stream, media_type=None, parser_context=None):
+14 result = super().parse(
+15 stream, media_type=media_type, parser_context=parser_context
+16 )
+17 data = {}
+18 new_files = {"files": []}
+ +20 # for case1 with nested serializers
+21 # parse each field with json
+22 for key, value in result.data.items():
+23 if not isinstance(value, str):
+24 data[key] = value
+25 continue
+26 if "{" in value or "[" in value:
+27 try:
+28 data[key] = json.loads(value)
+29 except ValueError:
+30 data[key] = value
+31 else:
+32 data[key] = value
+ +34 files = result.files.getlist("files")
+35 for file in files:
+36 new_files["files"].append({"file": file})
+ +38 return parsers.DataAndFiles(data, new_files)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""Contains custom DRF permissions classes for the workouts app
+2"""
+3from rest_framework import permissions
+4from workouts.models import Workout
+ + +7class IsOwner(permissions.BasePermission):
+8 """Checks whether the requesting user is also the owner of the existing object"""
+ +10 def has_object_permission(self, request, view, obj):
+11 return obj.owner == request.user
+ + +14class IsOwnerOfWorkout(permissions.BasePermission):
+15 """Checks whether the requesting user is also the owner of the new or existing object"""
+ +17 def has_permission(self, request, view):
+18 if request.method == "POST":
+19 if request.data.get("workout"):
+20 workout_id = request.data["workout"].split("/")[-2]
+21 workout = Workout.objects.get(pk=workout_id)
+22 if workout:
+23 return workout.owner == request.user
+24 return False
+ +26 return True
+ +28 def has_object_permission(self, request, view, obj):
+29 return obj.workout.owner == request.user
+ + +32class IsCoachAndVisibleToCoach(permissions.BasePermission):
+33 """Checks whether the requesting user is the existing object's owner's coach
+34 and whether the object (workout) has a visibility of Public or Coach.
+35 """
+ +37 def has_object_permission(self, request, view, obj):
+38 return obj.owner.coach == request.user and (
+39 obj.visibility == "PU" or obj.visibility == "CO"
+40 )
+ + +43class IsCoachOfWorkoutAndVisibleToCoach(permissions.BasePermission):
+44 """Checks whether the requesting user is the existing workout's owner's coach
+45 and whether the object has a visibility of Public or Coach.
+46 """
+ +48 def has_object_permission(self, request, view, obj):
+49 return obj.workout.owner.coach == request.user and (
+50 obj.workout.visibility == "PU" or obj.workout.visibility == "CO"
+51 )
+ + +54class IsPublic(permissions.BasePermission):
+55 """Checks whether the object (workout) has visibility of Public."""
+ +57 def has_object_permission(self, request, view, obj):
+58 return obj.visibility == "PU"
+ + +61class IsWorkoutPublic(permissions.BasePermission):
+62 """Checks whether the object's workout has visibility of Public."""
+ +64 def has_object_permission(self, request, view, obj):
+65 return obj.workout.visibility == "PU"
+ + +68class IsReadOnly(permissions.BasePermission):
+69 """Checks whether the HTTP request verb is only for retrieving data (GET, HEAD, OPTIONS)"""
+ +71 def has_object_permission(self, request, view, obj):
+72 return request.method in permissions.SAFE_METHODS
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""Serializers for the workouts application
+2"""
+3from rest_framework import serializers
+4from rest_framework.serializers import HyperlinkedRelatedField
+5from .models import Workout, Exercise, ExerciseInstance, WorkoutFile
+ + +8class ExerciseInstanceSerializer(serializers.HyperlinkedModelSerializer):
+9 """Serializer for an ExerciseInstance. Hyperlinks are used for relationships by default.
+ +11 Serialized fields: url, id, exercise, sets, number, workout
+ +13 Attributes:
+14 workout: The associated workout for this instance, represented by a hyperlink
+15 """
+ +17 workout = HyperlinkedRelatedField(
+18 queryset=Workout.objects.all(), view_name="workout-detail", required=False
+19 )
+ +21 class Meta:
+22 model = ExerciseInstance
+23 fields = ["url", "id", "exercise", "sets", "number", "workout"]
+ + +26class WorkoutFileSerializer(serializers.HyperlinkedModelSerializer):
+27 """Serializer for a WorkoutFile. Hyperlinks are used for relationships by default.
+ +29 Serialized fields: url, id, owner, file, workout
+ +31 Attributes:
+32 owner: The owner (User) of the WorkoutFile, represented by a username. ReadOnly
+33 workout: The associate workout for this WorkoutFile, represented by a hyperlink
+34 """
+ +36 owner = serializers.ReadOnlyField(source="owner.username")
+37 workout = HyperlinkedRelatedField(
+38 queryset=Workout.objects.all(), view_name="workout-detail", required=False
+39 )
+ +41 class Meta:
+42 model = WorkoutFile
+43 fields = ["url", "id", "owner", "file", "workout"]
+ +45 def create(self, validated_data):
+46 return WorkoutFile.objects.create(**validated_data)
+ + +49class WorkoutSerializer(serializers.HyperlinkedModelSerializer):
+50 """Serializer for a Workout. Hyperlinks are used for relationships by default.
+ +52 This serializer specifies nested serialization since a workout consists of WorkoutFiles
+53 and ExerciseInstances.
+ +55 Serialized fields: url, id, name, date, notes, owner, owner_username, visiblity,
+56 exercise_instances, files
+ +58 Attributes:
+59 owner_username: Username of the owning User
+60 exercise_instance: Serializer for ExericseInstances
+61 files: Serializer for WorkoutFiles
+62 """
+ +64 owner_username = serializers.SerializerMethodField()
+65 exercise_instances = ExerciseInstanceSerializer(many=True, required=True)
+66 files = WorkoutFileSerializer(many=True, required=False)
+ +68 class Meta:
+69 model = Workout
+70 fields = [
+71 "url",
+72 "id",
+73 "name",
+74 "date",
+75 "notes",
+76 "owner",
+77 "owner_username",
+78 "visibility",
+79 "exercise_instances",
+80 "files",
+81 ]
+82 extra_kwargs = {"owner": {"read_only": True}}
+ +84 def create(self, validated_data):
+85 """Custom logic for creating ExerciseInstances, WorkoutFiles, and a Workout.
+ +87 This is needed to iterate over the files and exercise instances, since this serializer is
+88 nested.
+ +90 Args:
+91 validated_data: Validated files and exercise_instances
+ +93 Returns:
+94 Workout: A newly created Workout
+95 """
+96 exercise_instances_data = validated_data.pop("exercise_instances")
+97 files_data = []
+98 if "files" in validated_data:
+99 files_data = validated_data.pop("files")
+ +101 workout = Workout.objects.create(**validated_data)
+ +103 for exercise_instance_data in exercise_instances_data:
+104 ExerciseInstance.objects.create(workout=workout, **exercise_instance_data)
+105 for file_data in files_data:
+106 WorkoutFile.objects.create(
+107 workout=workout, owner=workout.owner, file=file_data.get("file")
+108 )
+ +110 return workout
+ +112 def update(self, instance, validated_data):
+113 """Custom logic for updating a Workout with its ExerciseInstances and Workouts.
+ +115 Args:
+116 instance (Workout): Current Workout object
+117 validated_data: Contains data for validated fields
+ +119 Returns:
+120 Workout: Updated Workout instance
+121 """
+122 exercise_instances_data = validated_data.pop("exercise_instances", [])
+123 exercise_instances = list(instance.exercise_instances.all()) # Convert to list for consistent indexing
+ +125 instance.name = validated_data.get("name", instance.name)
+126 instance.notes = validated_data.get("notes", instance.notes)
+127 instance.visibility = validated_data.get("visibility", instance.visibility)
+128 instance.date = validated_data.get("date", instance.date)
+129 instance.save()
+ +131 # Handle ExerciseInstances
+132 for exercise_instance, exercise_instance_data in zip(
+133 exercise_instances, exercise_instances_data
+134 ):
+135 exercise_instance.exercise = exercise_instance_data.get(
+136 "exercise", exercise_instance.exercise
+137 )
+138 exercise_instance.number = exercise_instance_data.get(
+139 "number", exercise_instance.number
+140 )
+141 exercise_instance.sets = exercise_instance_data.get(
+142 "sets", exercise_instance.sets
+143 )
+144 exercise_instance.save()
+ +146 # If new exercise instances have been added, create them
+147 if len(exercise_instances_data) > len(exercise_instances):
+148 for i in range(len(exercise_instances), len(exercise_instances_data)):
+149 exercise_instance_data = exercise_instances_data[i]
+150 ExerciseInstance.objects.create(
+151 workout=instance, **exercise_instance_data
+152 )
+ +154 # If exercise instances have been removed, delete the extras
+155 elif len(exercise_instances_data) < len(exercise_instances):
+156 for i in range(len(exercise_instances_data), len(exercise_instances)):
+157 exercise_instances[i].delete()
+ +159 # Handle WorkoutFiles
+160 if "files" in validated_data:
+161 files_data = validated_data.pop("files")
+162 files = list(instance.files.all()) # Convert to list for consistent indexing
+ +164 for file, file_data in zip(files, files_data):
+165 file.file = file_data.get("file", file.file)
+166 file.save()
+ +168 # If new files have been added, create new WorkoutFiles
+169 if len(files_data) > len(files):
+170 for i in range(len(files), len(files_data)):
+171 WorkoutFile.objects.create(
+172 workout=instance,
+173 owner=instance.owner,
+174 file=files_data[i].get("file"),
+175 )
+ +177 # If files have been removed, delete the extras
+178 elif len(files_data) < len(files):
+179 for i in range(len(files_data), len(files)):
+180 files[i].delete()
+ +182 return instance
+ + +185 def get_owner_username(self, obj):
+186 """Returns the owning user's username
+ +188 Args:
+189 obj (Workout): Current Workout
+ +191 Returns:
+192 str: Username of owner
+193 """
+194 return obj.owner.username
+ + +197class ExerciseSerializer(serializers.HyperlinkedModelSerializer):
+198 """Serializer for an Exercise. Hyperlinks are used for relationships by default.
+ +200 Serialized fields: url, id, name, description, unit, instances
+ +202 Attributes:
+203 instances: Associated exercise instances with this Exercise type. Hyperlinks.
+204 """
+ +206 instances = serializers.HyperlinkedRelatedField(
+207 many=True, view_name="exerciseinstance-detail", read_only=True
+208 )
+ +210 class Meta:
+211 model = Exercise
+212 fields = ["url", "id", "name", "description", "unit", "instances"]
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.urls import path, include
+2from . import views
+3from rest_framework.urlpatterns import format_suffix_patterns
+ +5urlpatterns = format_suffix_patterns(
+6 [
+7 path("", views.api_root),
+8 path("api/workouts/", views.WorkoutList.as_view(), name="workout-list"),
+9 path(
+10 "api/workouts/<int:pk>/",
+11 views.WorkoutDetail.as_view(),
+12 name="workout-detail",
+13 ),
+14 path("api/exercises/", views.ExerciseList.as_view(), name="exercise-list"),
+15 path(
+16 "api/exercises/<int:pk>/",
+17 views.ExerciseDetail.as_view(),
+18 name="exercise-detail",
+19 ),
+20 path(
+21 "api/exercise-instances/",
+22 views.ExerciseInstanceList.as_view(),
+23 name="exercise-instance-list",
+24 ),
+25 path(
+26 "api/exercise-instances/<int:pk>/",
+27 views.ExerciseInstanceDetail.as_view(),
+28 name="exerciseinstance-detail",
+29 ),
+30 path(
+31 "api/workout-files/",
+32 views.WorkoutFileList.as_view(),
+33 name="workout-file-list",
+34 ),
+35 path(
+36 "api/workout-files/<int:pk>/",
+37 views.WorkoutFileDetail.as_view(),
+38 name="workoutfile-detail",
+39 ),
+40 path("", include("users.urls")),
+41 path("", include("comments.urls")),
+42 ]
+43)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""Contains views for the workouts application. These are mostly class-based views.
+2"""
+3from rest_framework import generics, mixins
+4from rest_framework import permissions
+ +6from rest_framework.parsers import (
+7 JSONParser,
+8)
+9from rest_framework.decorators import api_view
+10from rest_framework.response import Response
+11from rest_framework.reverse import reverse
+12from django.db.models import Q
+13from rest_framework import filters
+14from .parsers import MultipartJsonParser
+15from .permissions import (
+16 IsOwner,
+17 IsCoachAndVisibleToCoach,
+18 IsOwnerOfWorkout,
+19 IsCoachOfWorkoutAndVisibleToCoach,
+20 IsReadOnly,
+21 IsPublic,
+22 IsWorkoutPublic,
+23)
+24from .mixins import CreateListModelMixin
+25from .models import Workout, Exercise, ExerciseInstance, WorkoutFile
+26from .serializers import WorkoutSerializer, ExerciseSerializer
+27from .serializers import ExerciseInstanceSerializer, WorkoutFileSerializer
+28from rest_framework.response import Response
+ + +31@api_view(["GET"])
+32def api_root(request, format=None):
+33 return Response(
+34 {
+35 "users": reverse("user-list", request=request, format=format),
+36 "workouts": reverse("workout-list", request=request, format=format),
+37 "exercises": reverse("exercise-list", request=request, format=format),
+38 "exercise-instances": reverse(
+39 "exercise-instance-list", request=request, format=format
+40 ),
+41 "workout-files": reverse(
+42 "workout-file-list", request=request, format=format
+43 ),
+44 "comments": reverse("comment-list", request=request, format=format),
+45 "likes": reverse("like-list", request=request, format=format),
+46 }
+47 )
+ + +50class WorkoutList(
+51 mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView
+52):
+53 """Class defining the web response for the creation of a Workout, or displaying a list
+54 of Workouts
+ +56 HTTP methods: GET, POST
+57 """
+ +59 serializer_class = WorkoutSerializer
+60 permission_classes = [
+61 permissions.IsAuthenticated
+62 ] # User must be authenticated to create/view workouts
+63 parser_classes = [
+64 MultipartJsonParser,
+65 JSONParser,
+66 ] # For parsing JSON and Multi-part requests
+67 filter_backends = [filters.OrderingFilter]
+68 ordering_fields = ["name", "date", "owner__username"]
+ +70 def get(self, request, *args, **kwargs):
+71 return self.list(request, *args, **kwargs)
+ +73 def post(self, request, *args, **kwargs):
+74 return self.create(request, *args, **kwargs)
+ +76 def perform_create(self, serializer):
+77 serializer.save(owner=self.request.user)
+ +79 def get_queryset(self):
+80 qs = Workout.objects.none()
+81 if self.request.user:
+82 # A workout should be visible to the requesting user if any of the following hold:
+83 # - The workout has public visibility
+84 # - The owner of the workout is the requesting user
+85 # - The workout has coach visibility and the requesting user is the owner's coach
+86 qs = Workout.objects.filter(
+87 Q(visibility="PU")
+88 | Q(owner=self.request.user)
+89 | (Q(visibility="CO") & Q(owner__coach=self.request.user))
+90 ).distinct()
+ +92 return qs
+ + +95class WorkoutDetail(
+96 mixins.RetrieveModelMixin,
+97 mixins.UpdateModelMixin,
+98 mixins.DestroyModelMixin,
+99 generics.GenericAPIView,
+100):
+101 """Class defining the web response for the details of an individual Workout.
+ +103 HTTP methods: GET, PUT, DELETE
+104 """
+ +106 queryset = Workout.objects.all()
+107 serializer_class = WorkoutSerializer
+108 permission_classes = [
+109 permissions.IsAuthenticated
+110 & (IsOwner | (IsReadOnly & (IsCoachAndVisibleToCoach | IsPublic)))
+111 ]
+112 parser_classes = [MultipartJsonParser, JSONParser]
+ +114 def get(self, request, *args, **kwargs):
+115 return self.retrieve(request, *args, **kwargs)
+ +117 def put(self, request, *args, **kwargs):
+118 return self.update(request, *args, **kwargs)
+ +120 def delete(self, request, *args, **kwargs):
+121 return self.destroy(request, *args, **kwargs)
+ + +124class ExerciseList(
+125 mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView
+126):
+127 """Class defining the web response for the creation of an Exercise, or
+128 a list of Exercises.
+ +130 HTTP methods: GET, POST
+131 """
+ +133 queryset = Exercise.objects.all()
+134 serializer_class = ExerciseSerializer
+135 permission_classes = [permissions.IsAuthenticated]
+ +137 def get(self, request, *args, **kwargs):
+138 return self.list(request, *args, **kwargs)
+ +140 def post(self, request, *args, **kwargs):
+141 return self.create(request, *args, **kwargs)
+ + +144class ExerciseDetail(
+145 mixins.RetrieveModelMixin,
+146 mixins.UpdateModelMixin,
+147 mixins.DestroyModelMixin,
+148 generics.GenericAPIView,
+149):
+150 """Class defining the web response for the details of an individual Exercise.
+ +152 HTTP methods: GET, PUT, PATCH, DELETE
+153 """
+ +155 queryset = Exercise.objects.all()
+156 serializer_class = ExerciseSerializer
+157 permission_classes = [permissions.IsAuthenticated]
+ +159 def get(self, request, *args, **kwargs):
+160 return self.retrieve(request, *args, **kwargs)
+ +162 def put(self, request, *args, **kwargs):
+163 return self.update(request, *args, **kwargs)
+ +165 def patch(self, request, *args, **kwargs):
+166 return self.partial_update(request, *args, **kwargs)
+ +168 def delete(self, request, *args, **kwargs):
+169 return self.destroy(request, *args, **kwargs)
+ + +172class ExerciseInstanceList(
+173 mixins.ListModelMixin,
+174 mixins.CreateModelMixin,
+175 CreateListModelMixin,
+176 generics.GenericAPIView,
+177):
+178 """Class defining the web response for the creation"""
+ +180 serializer_class = ExerciseInstanceSerializer
+181 permission_classes = [permissions.IsAuthenticated & IsOwnerOfWorkout]
+ +183 def get(self, request, *args, **kwargs):
+184 return self.list(request, *args, **kwargs)
+ +186 def post(self, request, *args, **kwargs):
+187 return self.create(request, *args, **kwargs)
+ +189 def get_queryset(self):
+190 qs = ExerciseInstance.objects.none()
+191 if self.request.user:
+192 qs = ExerciseInstance.objects.filter(
+193 Q(workout__owner=self.request.user)
+194 | (
+195 (Q(workout__visibility="CO") | Q(workout__visibility="PU"))
+196 & Q(workout__owner__coach=self.request.user)
+197 )
+198 ).distinct()
+ +200 return qs
+ + +203class ExerciseInstanceDetail(
+204 mixins.RetrieveModelMixin,
+205 mixins.UpdateModelMixin,
+206 mixins.DestroyModelMixin,
+207 generics.GenericAPIView,
+208):
+209 serializer_class = ExerciseInstanceSerializer
+210 permission_classes = [
+211 permissions.IsAuthenticated
+212 & (
+213 IsOwnerOfWorkout
+214 | (IsReadOnly & (IsCoachOfWorkoutAndVisibleToCoach | IsWorkoutPublic))
+215 )
+216 ]
+ +218 queryset = ExerciseInstance.objects.all()
+ +220 def get(self, request, *args, **kwargs):
+221 return self.retrieve(request, *args, **kwargs)
+ +223 def put(self, request, *args, **kwargs):
+224 return self.update(request, *args, **kwargs)
+ +226 def patch(self, request, *args, **kwargs):
+227 return self.partial_update(request, *args, **kwargs)
+ +229 def delete(self, request, *args, **kwargs):
+230 return self.destroy(request, *args, **kwargs)
+ + +233class WorkoutFileList(
+234 mixins.ListModelMixin,
+235 mixins.CreateModelMixin,
+236 CreateListModelMixin,
+237 generics.GenericAPIView,
+238):
+ +240 queryset = WorkoutFile.objects.all()
+241 serializer_class = WorkoutFileSerializer
+242 permission_classes = [permissions.IsAuthenticated & IsOwnerOfWorkout]
+243 parser_classes = [MultipartJsonParser, JSONParser]
+ +245 def get(self, request, *args, **kwargs):
+246 return self.list(request, *args, **kwargs)
+ +248 def post(self, request, *args, **kwargs):
+249 return self.create(request, *args, **kwargs)
+ +251 def perform_create(self, serializer):
+252 serializer.save(owner=self.request.user)
+ +254 def get_queryset(self):
+255 qs = WorkoutFile.objects.none()
+256 if self.request.user:
+257 qs = WorkoutFile.objects.filter(
+258 Q(owner=self.request.user)
+259 | Q(workout__owner=self.request.user)
+260 | (
+261 Q(workout__visibility="CO")
+262 & Q(workout__owner__coach=self.request.user)
+263 )
+264 ).distinct()
+ +266 return qs
+ + +269class WorkoutFileDetail(
+270 mixins.RetrieveModelMixin,
+271 mixins.UpdateModelMixin,
+272 mixins.DestroyModelMixin,
+273 generics.GenericAPIView,
+274):
+ +276 queryset = WorkoutFile.objects.all()
+277 serializer_class = WorkoutFileSerializer
+278 permission_classes = [
+279 permissions.IsAuthenticated
+280 & (
+281 IsOwner
+282 | IsOwnerOfWorkout
+283 | (IsReadOnly & (IsCoachOfWorkoutAndVisibleToCoach | IsWorkoutPublic))
+284 )
+285 ]
+ +287 def get(self, request, *args, **kwargs):
+288 return self.retrieve(request, *args, **kwargs)
+ +290 def delete(self, request, *args, **kwargs):
+291 return self.destroy(request, *args, **kwargs)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1#Marks this folder as a python project
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework.test import APITestCase
+2from rest_framework import status
+3from django.urls import reverse
+4from workouts.models import Exercise
+5from django.contrib.auth import get_user_model
+ +7class WorkoutRobustBoundaryTestCase(APITestCase):
+8 def setUp(self):
+9 # Create a user
+10 self.user = get_user_model().objects.create_user(
+11 username="test_user", password="password123"
+12 )
+ +14 # Authenticate the user
+15 self.client.force_authenticate(user=self.user)
+ +17 # Create a valid exercise
+18 self.valid_exercise = Exercise.objects.create(
+19 name="Valid Exercise", description="A valid exercise", unit="reps"
+20 )
+ +22 def test_valid_workout_creation(self):
+23 """Test creating a workout with valid exercises."""
+24 url = reverse('workout-list')
+25 valid_workout_data = {
+26 "name": "Valid Workout",
+27 "date": "2025-04-01T10:00:00Z",
+28 "notes": "This is a valid workout",
+29 "visibility": "PU",
+30 "exercise_instances": [
+31 {
+32 "exercise": f"/api/exercises/{self.valid_exercise.id}/",
+33 "sets": 3,
+34 "number": 10
+35 }
+36 ]
+37 }
+ +39 response = self.client.post(url, valid_workout_data, format='json')
+40 print(response.content) # For debugging purposes
+ +42 # Assert that the workout was created successfully
+43 self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ +45 def test_invalid_workout_creation_future_date(self):
+46 """Test creating a workout with an invalid future date."""
+47 url = reverse('workout-list')
+48 invalid_workout_data = {
+49 "name": "Invalid Workout",
+50 "date": "2030-01-01T10:00:00Z", #Future date
+51 "notes": "This workout has an invalid future date",
+52 "visibility": "PU",
+53 "exercise_instances": [
+54 {
+55 "exercise": f"/api/exercises/{self.valid_exercise.id}/",
+56 "sets": 3,
+57 "number": 10
+58 }
+59 ]
+60 }
+ +62 response = self.client.post(url, invalid_workout_data, format='json')
+63 print(response.content) # For debugging purposes
+ +65 # Assert that the workout creation fails due to invalid date
+66 #self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ +68 def test_invalid_workout_creation_past_date(self):
+69 """Test creating a workout with an invalid future date."""
+70 url = reverse('workout-list')
+71 invalid_workout_data = {
+72 "name": "Invalid Workout 2",
+73 "date": "1900-01-01T10:00:00Z", #Future date
+74 "notes": "This workout has an invalid past date - part 2",
+75 "visibility": "PU",
+76 "exercise_instances": [
+77 {
+78 "exercise": f"/api/exercises/{self.valid_exercise.id}/",
+79 "sets": 3,
+80 "number": 10
+81 }
+82 ]
+83 }
+ + + + +88 response = self.client.post(url, invalid_workout_data, format='json')
+89 print(response.content) # For debugging purposes
+ +91 # Assert that the workout creation fails due to invalid date
+92 #self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework.test import APITestCase
+2from django.contrib.auth import get_user_model
+3from rest_framework import status
+4from users.models import Offer
+ +6User = get_user_model()
+ +8class AthleteCoachRequestTest(APITestCase):
+ +10 def setUp(self):
+11 """Set up the test environment with an athlete and multiple coaches."""
+12 self.athlete = User.objects.create_user(username="athlete", password="password")
+ +14 self.coach1 = User.objects.create_user(username="coach1", password="password", isCoach=True)
+15 self.coach2 = User.objects.create_user(username="coach2", password="password", isCoach=True)
+ +17 self.client.force_authenticate(user=self.athlete) # Authenticate as athlete
+ +19 def test_athlete_initially_has_no_coach(self):
+20 """Assert that an athlete has no assigned coach initially."""
+21 self.assertIsNone(self.athlete.coach)
+ +23 def test_athlete_sends_request_to_coach_and_coach_accepts(self):
+24 """Athlete sends request to a coach, and the coach accepts."""
+ +26 # Athlete sends request to coach1
+27 response = self.client.post(
+28 "/api/offers/",
+29 {
+30 "owner": self.athlete.id, # Athlete is the owner
+31 "recipient": f"http://testserver/api/users/{self.coach1.id}/", # Coach is the recipient
+32 "status": "p", # 'p' for Pending
+33 },
+34 format="json"
+35 )
+36 self.assertEqual(response.status_code, status.HTTP_201_CREATED) # Request created
+ +38 # Fetch offer object
+39 offer = Offer.objects.get(owner=self.athlete, recipient=self.coach1)
+ +41 # Coach accepts the offer
+42 response = self.client.put(
+43 f"/api/offers/{offer.id}/",
+44 {
+45 "status": "a", # 'a' for Accepted
+46 "recipient": f"http://testserver/api/users/{self.coach1.id}/",
+47 },
+48 )
+49 self.assertEqual(response.status_code, 200)
+ +51 # Verify that coach1 is assigned as the athlete's coach
+52 self.athlete.refresh_from_db()
+53 self.assertEqual(self.athlete.coach, self.coach1)
+ +55 def test_athlete_sends_requests_to_multiple_coaches_and_last_accepting_coach_is_assigned(self):
+56 """Athlete sends requests to multiple coaches; the last accepting coach should be assigned."""
+ +58 # Athlete sends requests to multiple coaches
+59 for coach in [self.coach1, self.coach2]:
+60 response = self.client.post(
+61 "/api/offers/",
+62 {
+63 "owner": self.athlete.id, # Athlete is the owner
+64 "recipient": f"http://testserver/api/users/{coach.id}/", # Coach is the recipient
+65 "status": "p", # 'p' for Pending
+66 },
+67 format="json"
+68 )
+69 self.assertEqual(response.status_code, status.HTTP_201_CREATED) # Request created
+ +71 # Coach1 accepts the offer
+72 offer1 = Offer.objects.get(owner=self.athlete, recipient=self.coach1)
+73 response = self.client.put(
+74 f"/api/offers/{offer1.id}/",
+75 {
+76 "status": "a", # Accepting the offer
+77 "recipient": f"http://testserver/api/users/{self.coach1.id}/",
+78 },
+79 )
+80 self.assertEqual(response.status_code, 200)
+ +82 # Coach2 accepts the offer
+83 offer2 = Offer.objects.get(owner=self.athlete, recipient=self.coach2)
+84 response = self.client.put(
+85 f"/api/offers/{offer2.id}/",
+86 {
+87 "status": "a", # Accepting the offer
+88 "recipient": f"http://testserver/api/users/{self.coach2.id}/",
+89 },
+90 )
+91 self.assertEqual(response.status_code, 200)
+ +93 # Verify that coach2 is assigned as the athlete's coach (last accepting coach)
+94 self.athlete.refresh_from_db()
+95 self.assertEqual(self.athlete.coach, self.coach2)
+ +97 def test_multiple_requests_to_single_coach_all_other_requests_get_deleted_on_acceptance(self):
+98 """Athlete sends multiple requests to a single coach, only one gets accepted, others should be removed."""
+ +100 # Athlete sends multiple requests to coach1
+101 for _ in range(3): # Simulating multiple requests
+102 response = self.client.post(
+103 "/api/offers/",
+104 {
+105 "owner": self.athlete.id, # Athlete is the owner
+106 "recipient": f"http://testserver/api/users/{self.coach1.id}/", # Coach is the recipient
+107 "status": "p", # 'p' for Pending
+108 },
+109 format="json"
+110 )
+111 self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ +113 # Fetch all offers by athlete to coach1
+114 all_offers = Offer.objects.filter(owner=self.athlete, recipient=self.coach1)
+115 self.assertGreater(len(all_offers), 1) # Ensure multiple requests exist
+ +117 # Coach1 accepts one offer
+118 response = self.client.put(
+119 f"/api/offers/{all_offers[0].id}/",
+120 {
+121 "status": "a", # Accepting the offer
+122 "recipient": f"http://testserver/api/users/{self.coach1.id}/",
+123 },
+124 )
+125 self.assertEqual(response.status_code, 200)
+ +127 # Verify that all other pending offers from athlete to coach1 were deleted
+128 remaining_offers = Offer.objects.filter(owner=self.athlete, recipient=self.coach1)
+129 self.assertEqual(len(remaining_offers), 1) # Only one accepted offer should remain
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework.test import APITestCase
+2from rest_framework import status
+3from django.urls import reverse
+4from workouts.models import Exercise
+5from django.contrib.auth import get_user_model
+ +7class WorkoutRobustBoundaryTestCase(APITestCase):
+8 def setUp(self):
+9 # Create a user
+10 self.user = get_user_model().objects.create_user(
+11 username="test_user", password="password123"
+12 )
+ +14 # Authenticate the user
+15 self.client.force_authenticate(user=self.user)
+ +17 # Create a valid exercise
+18 self.valid_exercise = Exercise.objects.create(
+19 name="Valid Exercise", description="A valid exercise", unit="reps"
+20 )
+ +22 def test_valid_workout_creation(self):
+23 """Test creating a workout with valid exercises."""
+24 url = reverse('workout-list')
+25 valid_workout_data = {
+26 "name": "Valid Workout",
+27 "date": "2025-04-01T10:00:00Z",
+28 "notes": "This is a valid workout",
+29 "visibility": "PU",
+30 "exercise_instances": [
+31 {
+32 "exercise": f"/api/exercises/{self.valid_exercise.id}/",
+33 "sets": 3,
+34 "number": 10
+35 }
+36 ]
+37 }
+ +39 response = self.client.post(url, valid_workout_data, format='json')
+40 print(response.content) # For debugging purposes
+ +42 # Assert that the workout was created successfully
+43 self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ +45 def test_invalid_workout_creation(self):
+46 """Test creating a workout with invalid exercises ie using boundary values."""
+47 url = reverse('workout-list')
+48 invalid_workout_data = {
+49 "name": "Invalid Workout",
+50 "date": "2025-04-01T10:00:00Z",
+51 "notes": "This workout has boundary values for the exercises",
+52 "visibility": "PU",
+53 "exercise_instances": [
+54 {
+55 "exercise": f"/api/exercises/{self.valid_exercise.id}/",
+56 "sets": -3, # Invalid: negative sets
+57 "number": -10 # Invalid: negative number
+58 }
+59 ]
+60 }
+ +62 response = self.client.post(url, invalid_workout_data, format='json')
+63 print(response.content) # For debugging purposes
+ +65 # Assert that the workout creation fails due to invalid exercise instances
+66 self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+67 #self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# This is for coverage testing independent of manage.py
+2'''
+3import os
+4import django
+5import sys
+6sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+7os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secfit.settings')
+8django.setup()
+9'''
+ + +12from django.test import TestCase
+13from django.core.files.uploadedfile import SimpleUploadedFile
+14from users.models import AthleteFile
+15from workouts.models import Workout
+16from django.contrib.auth import get_user_model
+17from django.utils.timezone import now
+ +19class TestSpecialCharacterFileName(TestCase):
+20 def setUp(self):
+21 # Create a test user (coach)
+22 User = get_user_model()
+23 self.coach = User.objects.create_user(username="test_coach", password="password123", isCoach=True)
+ +25 # Create an athlete and assign the coach
+26 self.athlete = User.objects.create_user(username="test_athlete", password="password123", isCoach=False, coach=self.coach)
+ +28 # Create a workout for the athlete
+29 self.workout = Workout.objects.create(owner=self.athlete, name="Test Workout", date=now())
+ +31 # Force authentication for the coach
+32 from rest_framework.test import APIClient
+33 self.client = APIClient() # Use APIClient for forced authentication
+34 self.client.force_authenticate(user=self.coach)
+ +36 def test_upload_file_with_special_characters_in_name(self):
+37 """
+38 Test uploading a file with special characters in its name.
+39 Expected behavior:
+40 1. The system should sanitize the file name by removing special characters and transforming spaces into underscores.
+41 2. A unique identifier should be appended to the sanitized name.
+42 3. The file should be saved in the database and associated with the correct owner and athlete.
+ +44 Steps:
+45 1. Create a file with special characters in its name.
+46 2. Upload the file using the API.
+47 3. Verify the response status code is 201 (successful creation).
+48 4. Check that the file is saved in the database with the sanitized name.
+49 5. Verify the file is associated with the correct owner and athlete.
+50 """
+51 # Create a file with special characters in its name
+52 special_char_file = SimpleUploadedFile("file@#$ %&()a.png", b"dummy content", content_type="image/png")
+ +54 # Upload file
+55 response = self.client.post(
+56 "/api/athlete-files/",
+57 {
+58 "file": special_char_file,
+59 "workout": self.workout.id,
+60 "athlete": f"/api/users/{self.athlete.id}/", # Include the athlete field
+61 },
+62 format="multipart"
+63 )
+ +65 self.assertEqual(response.status_code, 201) # Successful creation
+ +67 # File was saved with a sanitized name and unique identifier?
+68 saved_file = AthleteFile.objects.first()
+69 self.assertIsNotNone(saved_file, "The file was not saved in the database.")
+ +71 # Extract the base name of the file (without the directory path)
+72 saved_file_name = saved_file.file.name.split("/")[-1]
+ +74 # Saved file name starts with "file" and ends with ".png"?
+75 self.assertTrue(
+76 saved_file_name.startswith("file_a") and saved_file_name.endswith(".png"),
+77 f"Expected file name to start with 'file_a' and end with '.png', but got '{saved_file_name}'."
+78 )
+ +80 # File is associated with the correct owner and athlete?
+81 self.assertEqual(saved_file.owner, self.coach)
+82 self.assertEqual(saved_file.athlete, self.athlete)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework.test import APITestCase, APIClient # Import APIClient
+2from django.contrib.auth import get_user_model
+3from workouts.models import Workout
+4from django.utils.timezone import now
+ +6User = get_user_model()
+ +8class TestCoachViewAthleteWorkouts(APITestCase): # Use APITestCase instead of TestCase
+9 def setUp(self):
+10 # Initialize the API client
+11 self.client = APIClient()
+ +13 # Create coach and athlete
+14 self.coach = User.objects.create_user(username="test_coach", password="password123", isCoach=True)
+15 self.athlete = User.objects.create_user(username="test_athlete", password="password123", isCoach=False, coach=self.coach)
+ +17 # Create workouts for the athlete
+18 self.workout1 = Workout.objects.create(name="Workout 1", owner=self.athlete, date=now())
+19 self.workout2 = Workout.objects.create(name="Workout 2", owner=self.athlete, date=now())
+ +21 # Create another athlete not assigned to the coach
+22 self.other_athlete = User.objects.create_user(username="other_athlete", password="password123", isCoach=False)
+ +24 # Create workouts for the other athlete
+25 self.other_workout1 = Workout.objects.create(name="Other Workout 1", owner=self.other_athlete, date=now())
+26 self.other_workout2 = Workout.objects.create(name="Other Workout 2", owner=self.other_athlete, date=now())
+ +28 # Force authentication for the coach
+29 self.client.force_authenticate(user=self.coach)
+ +31 def test_coach_can_view_assigned_athlete_workouts(self):
+32 '''
+33 Test that the coach can view workouts assigned to their athlete.
+34 Expected behavior:
+35 1. The coach should be able to view all workouts assigned to their athlete.
+36 2. The workouts should be filtered based on the coach's ID.
+37 3. The response should include the correct workout details.
+38 Steps:
+39 1. Create a coach and an athlete.
+40 2. Assign workouts to the athlete.
+41 3. Create another athlete not assigned to the coach and assign workouts to them.
+42 4. Use coach's credentials to access the API endpoint for viewing workouts.
+43 5. Verify the response status code is 200.
+44 6. Check that the response contains only the workouts assigned to the coach's athlete.
+45 '''
+46 # 4. Access the API endpoint to view workouts
+47 response = self.client.get("/api/workouts/")
+ +49 # 5. Verify response status code
+50 self.assertEqual(response.status_code, 200, "The response status code is not 200")
+ +52 # Check that the response contains the correct workout details
+53 response_data = response.json()
+54 workout_names = [workout["name"] for workout in response_data]
+ +56 # Coach can see their athlete's workouts?
+57 self.assertIn("Workout 1", workout_names, "Workout 1 is not in the response")
+58 self.assertIn("Workout 2", workout_names, "Workout 2 is not in the response")
+ +60 # Coach cannot see workouts of other athletes?
+61 self.assertNotIn("Other Workout 1", workout_names, "Other Workout 1 should not be in the response")
+62 self.assertNotIn("Other Workout 2", workout_names, "Other Workout 2 should not be in the response")
+ +64 # Workouts are filtered based on the coach's ID?
+65 for workout in response_data:
+66 owner_id = int(workout["owner"].split("/")[-2])
+67 self.assertEqual(owner_id, self.athlete.id, "The workout is not associated with the correct athlete")
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# Generated by Django 4.0.8 on 2024-07-29 12:05
+ +3from django.conf import settings
+4from django.db import migrations, models
+5import django.db.models.deletion
+6import workouts.models
+ + +9class Migration(migrations.Migration):
+ +11 initial = True
+ +13 dependencies = [
+14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+15 ]
+ +17 operations = [
+18 migrations.CreateModel(
+19 name='Exercise',
+20 fields=[
+21 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+22 ('name', models.CharField(max_length=100)),
+23 ('description', models.TextField()),
+24 ('unit', models.CharField(max_length=50)),
+25 ],
+26 ),
+27 migrations.CreateModel(
+28 name='Workout',
+29 fields=[
+30 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+31 ('name', models.CharField(max_length=100)),
+32 ('date', models.DateTimeField()),
+33 ('notes', models.TextField()),
+34 ('visibility', models.CharField(choices=[('PU', 'Public'), ('CO', 'Coach'), ('PR', 'Private')], default='CO', max_length=2)),
+35 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workouts', to=settings.AUTH_USER_MODEL)),
+36 ],
+37 options={
+38 'ordering': ['-date'],
+39 },
+40 ),
+41 migrations.CreateModel(
+42 name='WorkoutFile',
+43 fields=[
+44 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+45 ('file', models.FileField(upload_to=workouts.models.workout_directory_path)),
+46 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workout_files', to=settings.AUTH_USER_MODEL)),
+47 ('workout', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='workouts.workout')),
+48 ],
+49 ),
+50 migrations.CreateModel(
+51 name='ExerciseInstance',
+52 fields=[
+53 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+54 ('sets', models.IntegerField()),
+55 ('number', models.IntegerField()),
+56 ('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='workouts.exercise')),
+57 ('workout', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercise_instances', to='workouts.workout')),
+58 ],
+59 ),
+60 ]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""
+2ASGI config for secfit project.
+ +4It exposes the ASGI callable as a module-level variable named ``application``.
+ +6For more information on this file, see
+7https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
+8"""
+ +10import os
+ +12from django.core.asgi import get_asgi_application
+ +14os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secfit.settings')
+ +16application = get_asgi_application()
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""
+2Django settings for secfit project.
+ +4Generated by 'django-admin startproject' using Django 4.1.
+ +6For more information on this file, see
+7https://docs.djangoproject.com/en/4.1/topics/settings/
+ +9For the full list of settings and their values, see
+10https://docs.djangoproject.com/en/4.1/ref/settings/
+11"""
+ +13from datetime import timedelta
+14from pathlib import Path
+15import os
+ +17# Build paths inside the project like this: BASE_DIR / 'subdir'.
+18BASE_DIR = Path(__file__).resolve().parent.parent
+ +20MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+21MEDIA_URL = '/media/'
+22# Quick-start development settings - unsuitable for production
+23# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
+ +25# SECURITY WARNING: keep the secret key used in production secret!
+26SECRET_KEY = 'django-insecure-*=))=v-+@_c-6(-60o%nv2b^a8br%$)%k+u+%(9ayozs79)abc'
+ +28# SECURITY WARNING: don't run with debug turned on in production!
+29DEBUG = True
+ +31ALLOWED_HOSTS = ["0.0.0.0", "localhost", "127.0.0.1", ".idi.ntnu.no"]
+ + +34# Application definition
+ +36INSTALLED_APPS = [
+37 'django.contrib.admin',
+38 'django.contrib.auth',
+39 'django.contrib.contenttypes',
+40 'django.contrib.sessions',
+41 'django.contrib.messages',
+42 'django.contrib.staticfiles',
+43 "workouts.apps.WorkoutsConfig",
+44 "users.apps.UsersConfig",
+45 "comments.apps.CommentsConfig",
+46 'rest_framework',
+47 'rest_framework_simplejwt.token_blacklist',
+48 'corsheaders',
+49]
+ +51MIDDLEWARE = [
+52 'corsheaders.middleware.CorsMiddleware',
+53 'django.middleware.security.SecurityMiddleware',
+54 'django.contrib.sessions.middleware.SessionMiddleware',
+55 'django.middleware.common.CommonMiddleware',
+56 'django.contrib.auth.middleware.AuthenticationMiddleware',
+57 'django.contrib.messages.middleware.MessageMiddleware',
+58 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+59]
+ +61CORS_ORIGIN_ALLOW_ALL = (
+62 True
+63)
+64CORS_ORIGIN_WHITELIST = [
+65 'http://localhost:3000',
+66]
+67CORS_ALLOW_METHODS = [
+68 'DELETE',
+69 'GET',
+70 'OPTIONS',
+71 'PATCH',
+72 'POST',
+73 'PUT',
+74]
+75ROOT_URLCONF = 'secfit.urls'
+76AUTH_USER_MODEL = 'users.User'
+ +78AUTHENTICATION_BACKENDS = [
+79 'users.auth_backend.UsernameAuthBackend'
+80]
+ +82REST_FRAMEWORK = {
+83 # Disabled default pagination
+84 "DEFAULT_PAGINATION_CLASS": None,
+85 "PAGE_SIZE": None,
+86 "DEFAULT_AUTHENTICATION_CLASSES": (
+87 "rest_framework_simplejwt.authentication.JWTAuthentication",
+88 ),
+89}
+ +91TEMPLATES = [
+92 {
+93 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+94 'DIRS': [],
+95 'APP_DIRS': True,
+96 'OPTIONS': {
+97 'context_processors': [
+98 'django.template.context_processors.debug',
+99 'django.template.context_processors.request',
+100 'django.contrib.auth.context_processors.auth',
+101 'django.contrib.messages.context_processors.messages',
+102 ],
+103 },
+104 },
+105]
+ +107WSGI_APPLICATION = 'secfit.wsgi.application'
+ + +110# Database
+111# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
+ +113DATABASES = {
+114 'default': {
+115 'ENGINE': 'django.db.backends.sqlite3',
+116 'NAME': BASE_DIR / 'db.sqlite3',
+117 }
+118}
+ + +121# Password validation
+122# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
+ +124AUTH_PASSWORD_VALIDATORS = [
+125 {
+126 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+127 },
+128 {
+129 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+130 },
+131 {
+132 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+133 },
+134 {
+135 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+136 },
+137]
+ +139PASSWORD_HASHERS = [
+140 'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher',
+141]
+ + +144# Internationalization
+145# https://docs.djangoproject.com/en/4.1/topics/i18n/
+ +147LANGUAGE_CODE = 'en-us'
+ +149TIME_ZONE = 'UTC'
+ +151USE_I18N = True
+ +153USE_TZ = True
+ + +156# Static files (CSS, JavaScript, Images)
+157# https://docs.djangoproject.com/en/4.1/howto/static-files/
+ +159STATIC_URL = 'static/'
+ +161# Default primary key field type
+162# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
+ +164DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+ +166SIMPLE_JWT = {
+167 'ACCESS_TOKEN_LIFETIME': timedelta(hours=72),
+168 'REFRESH_TOKEN_LIFETIME': timedelta(days=60),
+169 'ROTATE_REFRESH_TOKENS': True,
+170}
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""secfit URL Configuration
+ +3The `urlpatterns` list routes URLs to views. For more information please see:
+4 https://docs.djangoproject.com/en/4.1/topics/http/urls/
+5Examples:
+6Function views
+7 1. Add an import: from my_app import views
+8 2. Add a URL to urlpatterns: path('', views.home, name='home')
+9Class-based views
+10 1. Add an import: from other_app.views import Home
+11 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+12Including another URLconf
+13 1. Import the include() function: from django.urls import include, path
+14 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+15"""
+16from django.contrib import admin
+17from django.urls import path,include
+18from django.conf.urls.static import static
+19from django.conf import settings
+20from rest_framework.routers import DefaultRouter
+ +22router = DefaultRouter()
+ +24urlpatterns = [
+25 path('admin/', admin.site.urls),
+26 path('api/', include(router.urls)), # DRF API root
+27 path('',include('workouts.urls')),
+ +29] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1"""
+2WSGI config for secfit project.
+ +4It exposes the WSGI callable as a module-level variable named ``application``.
+ +6For more information on this file, see
+7https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/
+8"""
+ +10import os
+ +12from django.core.wsgi import get_wsgi_application
+ +14os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'secfit.settings')
+ +16application = get_wsgi_application()
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ ++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.contrib import admin
+2from django.contrib.auth.admin import UserAdmin
+3from .models import Offer, AthleteFile
+4from django.contrib.auth import get_user_model
+5from .forms import CustomUserChangeForm, CustomUserCreationForm
+ +7class CustomUserAdmin(UserAdmin):
+8 add_form = CustomUserCreationForm
+9 form = CustomUserChangeForm
+10 model = get_user_model()
+11 fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("coach","isCoach","specialism")}),)
+12 add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("coach","isCoach","specialism")}),)
+ + +15admin.site.register(get_user_model(), CustomUserAdmin)
+16admin.site.register(Offer)
+17admin.site.register(AthleteFile)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.apps import AppConfig
+ + +4class UsersConfig(AppConfig):
+5 name = "users"
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.contrib.auth import get_user_model
+2User = get_user_model()
+ +4class UsernameAuthBackend:
+5 def authenticate(self,request, username=None, password=None):
+6 try:
+7 user = User.objects.get(username=username)
+8 if user.check_password(password):
+9 return user
+10 except User.DoesNotExist:
+11 return None
+ +13 def get_user(self,user_id):
+14 try:
+15 return User.objects.get(pk=user_id)
+16 except User.DoesNotExist:
+17 return None
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django import forms
+2from django.contrib.auth.forms import UserCreationForm, UserChangeForm
+3from django.contrib.auth import get_user_model
+ + +6class CustomUserCreationForm(UserCreationForm):
+7 class Meta(UserCreationForm):
+8 model = get_user_model()
+9 fields = ("username", "coach")
+ + +12class CustomUserChangeForm(UserChangeForm):
+13 class Meta:
+14 model = get_user_model()
+15 fields = ("username", "coach")
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.db import models
+2from django.contrib.auth.models import AbstractUser
+3from django.contrib.auth import get_user_model
+4from .validators import FileValidator
+ + +7class User(AbstractUser):
+8 """
+9 Standard Django User model with an added field for a user's coach.
+10 """
+11 isCoach = models.fields.BooleanField(default=False)
+12 coach = models.ForeignKey(
+13 "self", on_delete=models.CASCADE, related_name="athletes", blank=True, null=True
+14 )
+15 specialism = models.fields.CharField(max_length=1000,default="")
+ + + +19def athlete_directory_path(instance, filename):
+20 """
+21 Return the path for an athlete's file
+22 :param instance: Current instance containing an athlete
+23 :param filename: Name of the file
+24 :return: Path of file as a string
+25 """
+26 return f"users/{instance.athlete.id}/{filename}"
+ + +29class AthleteFile(models.Model):
+30 """
+31 Model for an athlete's file. Contains fields for the athlete for whom this file was uploaded,
+32 the coach owner, and the file itself.
+33 """
+ +35 athlete = models.ForeignKey(
+36 get_user_model(), on_delete=models.CASCADE, related_name="coach_files"
+37 )
+38 owner = models.ForeignKey(
+39 get_user_model(), on_delete=models.CASCADE, related_name="athlete_files"
+40 )
+41 file = models.FileField(upload_to=athlete_directory_path, validators=[FileValidator(
+42 allowed_mimetypes='', allowed_extensions='', max_size=1024*1024*5)]
+43 )
+ + +46class Offer(models.Model):
+47 """Django model for a coaching offer that one user sends to another.
+ +49 Each offer has an owner, a recipient, a status, and a timestamp.
+ +51 Attributes:
+52 owner: Who sent the offer
+53 recipient: Who received the offer
+54 status: The current status of the offer (accept, declined, or pending)
+55 timestamp: When the offer was sent.
+56 """
+57 owner = models.ForeignKey(
+58 get_user_model(), on_delete=models.CASCADE, related_name="sent_offers"
+59 )
+60 recipient = models.ForeignKey(
+61 get_user_model(), on_delete=models.CASCADE, related_name="received_offers"
+62 )
+ +64 ACCEPTED = "a"
+65 PENDING = "p"
+66 DECLINED = "d"
+67 STATUS_CHOICES = (
+68 (ACCEPTED, "Accepted"),
+69 (PENDING, "Pending"),
+70 (DECLINED, "Declined"),
+71 )
+ +73 status = models.CharField(max_length=8, choices=STATUS_CHOICES, default=PENDING)
+74 timestamp = models.DateTimeField(auto_now_add=True)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework import permissions
+2from django.contrib.auth import get_user_model
+ + +5class IsCurrentUser(permissions.BasePermission):
+6 def has_object_permission(self, request, view, obj):
+7 return obj == request.user
+ + +10class IsAthlete(permissions.BasePermission):
+11 def has_permission(self, request, view):
+12 if request.method == "POST":
+13 if request.data.get("athlete"):
+14 athlete_id = request.data["athlete"].split("/")[-2]
+15 return athlete_id == request.user.id
+16 return False
+ +18 return True
+ +20 def has_object_permission(self, request, view, obj):
+21 return request.user == obj.athlete
+ + +24class IsCoach(permissions.BasePermission):
+25 def has_permission(self, request, view):
+26 if request.method == "POST":
+27 if request.data.get("athlete"):
+28 athlete_id = request.data["athlete"].split("/")[-2]
+29 athlete = get_user_model().objects.get(pk=athlete_id)
+30 return athlete.coach == request.user
+31 return False
+ +33 return True
+ +35 def has_object_permission(self, request, view, obj):
+36 return request.user == obj.athlete.coach
+ + +39class IsRecipientOfOffer(permissions.BasePermission):
+40 """Checks whether the user is the recipient of the offer"""
+ +42 def has_permission(self, request, view):
+43 if request.method == "PUT":
+44 if request.data.get("recipient"):
+45 recipient_id = request.data["recipient"].split("/")[-2]
+46 recipient = get_user_model().objects.get(pk=recipient_id)
+47 if recipient:
+48 return recipient == request.user
+49 return False
+ +51 return True
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework import serializers
+2from django.contrib.auth import get_user_model, password_validation
+3from .models import Offer, AthleteFile
+4from django import forms
+ + +7class UserSerializer(serializers.HyperlinkedModelSerializer):
+8 password = serializers.CharField(style={"input_type": "password"}, write_only=True)
+9 password1 = serializers.CharField(style={"input_type": "password"}, write_only=True)
+ +11 class Meta:
+12 model = get_user_model()
+13 fields = [
+14 "url",
+15 "id",
+16 "email",
+17 "username",
+18 "password",
+19 "password1",
+20 "athletes",
+21 "isCoach",
+22 "coach",
+23 "specialism",
+24 "workouts",
+25 "coach_files",
+26 "athlete_files",
+27 ]
+ +29 def validate_password(self, value):
+30 data = self.get_initial()
+ +32 password = data.get("password")
+33 password1 = data.get("password1")
+ +35 try:
+36 password_validation.validate_password(password)
+37 except forms.ValidationError as error:
+38 raise serializers.ValidationError(error.messages)
+ +40 if password != password1:
+41 raise serializers.ValidationError("Passwords must match!")
+ +43 return value
+ +45 def create(self, validated_data):
+46 username = validated_data["username"]
+47 email = validated_data["email"]
+48 isCoach = validated_data["isCoach"]
+49 if (isCoach):
+50 specialism = validated_data["specialism"]
+51 user_obj = get_user_model()(username=username, email=email,isCoach=isCoach,specialism=specialism)
+52 else:
+53 user_obj = get_user_model()(username=username, email=email,isCoach=isCoach)
+54 password = validated_data["password"]
+55 user_obj.set_password(password)
+56 user_obj.save()
+ +58 return user_obj
+ + +61class UserGetSerializer(serializers.HyperlinkedModelSerializer):
+62 class Meta:
+63 model = get_user_model()
+64 fields = [
+65 "url",
+66 "id",
+67 "email",
+68 "username",
+69 "athletes",
+70 "isCoach",
+71 "specialism",
+72 "coach",
+73 "workouts",
+74 "coach_files",
+75 "athlete_files",
+76 ]
+ + +79class UserPutSerializer(serializers.ModelSerializer):
+80 class Meta:
+81 model = get_user_model()
+82 fields = ["athletes"]
+ +84 def update(self, instance, validated_data):
+85 athletes_data = validated_data["athletes"]
+86 instance.athletes.set(athletes_data)
+ +88 return instance
+ + +91class AthleteFileSerializer(serializers.HyperlinkedModelSerializer):
+92 owner = serializers.ReadOnlyField(source="owner.username")
+ +94 class Meta:
+95 model = AthleteFile
+96 fields = ["url", "id", "owner", "file", "athlete"]
+ +98 def create(self, validated_data):
+99 return AthleteFile.objects.create(**validated_data)
+ + +102class OfferSerializer(serializers.HyperlinkedModelSerializer):
+103 owner = serializers.ReadOnlyField(source="owner.username")
+ +105 class Meta:
+106 model = Offer
+107 fields = [
+108 "url",
+109 "id",
+110 "owner",
+111 "recipient",
+112 "status",
+113 "timestamp",
+114 ]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from django.urls import path, include
+2from . import views
+3from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView, TokenObtainPairView, TokenBlacklistView
+ +5urlpatterns = [
+6 path("api/users/", views.UserList.as_view(), name="user-list"),
+7 path("api/users/<int:pk>/", views.UserDetail.as_view(), name="user-detail"),
+8 path("api/users/<str:username>/", views.UserDetail.as_view(), name="user-detail"),
+9 path("api/offers/", views.OfferList.as_view(), name="offer-list"),
+10 path("api/offers/<int:pk>/", views.OfferDetail.as_view(), name="offer-detail"),
+11 path(
+12 "api/athlete-files/", views.AthleteFileList.as_view(), name="athlete-file-list"
+13 ),
+14 path(
+15 "api/athlete-files/<int:pk>/",
+16 views.AthleteFileDetail.as_view(),
+17 name="athletefile-detail",
+18 ),
+19 path("api/auth/", include("rest_framework.urls")),
+20 path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
+21 path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
+22 path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
+23 path("api/logout/", TokenBlacklistView.as_view(), name="token_blacklist")
+24]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# Retrieved from https://gist.github.com/mobula/da99e4db843b9ceb3a3f
+2# Modified to remove ImageValidator since it is not needed for this project.
+ +4# @brief
+5# Performs file upload validation for django.
+6# with Django 1.7 migrations support (deconstructible)
+ +8# @author dokterbob
+9# @author jrosebr1
+10# @author mobula
+ +12import mimetypes
+13from os.path import splitext
+ +15from django.core.exceptions import ValidationError
+16from django.utils.translation import gettext_lazy as _
+17from django.template.defaultfilters import filesizeformat
+ +19from django.utils.deconstruct import deconstructible
+ + + +23@deconstructible
+24class FileValidator(object):
+25 """
+26 Validator for files, checking the size, extension and mimetype.
+ +28 Initialization parameters:
+29 allowed_extensions: iterable with allowed file extensions
+30 ie. ('txt', 'doc')
+31 allowed_mimetypes: iterable with allowed mimetypes
+32 ie. ('image/png', )
+33 min_size: minimum number of bytes allowed
+34 ie. 100
+35 max_size: maximum number of bytes allowed
+36 ie. 24*1024*1024 for 24 MB
+ +38 Usage example::
+ +40 MyModel(models.Model):
+41 myfile = FileField(validators=FileValidator(max_size=24*1024*1024), ...)
+ +43 """
+ +45 messages = {
+46 'extension_not_allowed': _("Extension '%(extension)s' not allowed. Allowed extensions are: '%(allowed_extensions)s.'"),
+47 'mimetype_not_allowed': _("MIME type '%(mimetype)s' is not valid. Allowed types are: %(allowed_mimetypes)s."),
+48 'min_size': _('The current file %(size)s, which is too small. The minumum file size is %(allowed_size)s.'),
+49 'max_size': _('The current file %(size)s, which is too large. The maximum file size is %(allowed_size)s.')
+50 }
+ +52 mime_message = _("MIME type '%(mimetype)s' is not valid. Allowed types are: %(allowed_mimetypes)s.")
+53 min_size_message = _('The current file %(size)s, which is too small. The minumum file size is %(allowed_size)s.')
+54 max_size_message = _('The current file %(size)s, which is too large. The maximum file size is %(allowed_size)s.')
+ +56 def __init__(self, *args, **kwargs):
+57 self.allowed_extensions = kwargs.pop('allowed_extensions', None)
+58 self.allowed_mimetypes = kwargs.pop('allowed_mimetypes', None)
+59 self.min_size = kwargs.pop('min_size', 0)
+60 self.max_size = kwargs.pop('max_size', None)
+ +62 def __eq__(self, other):
+63 return ( isinstance(other, FileValidator)
+64 and (self.allowed_extensions == other.allowed_extensions)
+65 and (self.allowed_mimetypes == other.allowed_mimetypes)
+66 and (self.min_size == other.min_size)
+67 and (self.max_size == other.max_size)
+68 )
+ +70 def __call__(self, value):
+71 """
+72 Check the extension, content type and file size.
+73 """
+ +75 # Check the extension
+76 ext = splitext(value.name)[1][1:].lower()
+77 if self.allowed_extensions and not ext in self.allowed_extensions:
+78 code = 'extension_not_allowed'
+79 message = self.messages[code]
+80 params = {
+81 'extension' : ext,
+82 'allowed_extensions': ', '.join(self.allowed_extensions)
+83 }
+84 raise ValidationError(message=message, code=code, params=params)
+ +86 # Check the content type
+87 mimetype = mimetypes.guess_type(value.name)[0]
+88 if self.allowed_mimetypes and not mimetype in self.allowed_mimetypes:
+89 code = 'mimetype_not_allowed'
+90 message = self.messages[code]
+91 params = {
+92 'mimetype': mimetype,
+93 'allowed_mimetypes': ', '.join(self.allowed_mimetypes)
+94 }
+95 raise ValidationError(message=message, code=code, params=params)
+ +97 # Check the file size
+98 filesize = len(value)
+99 if self.max_size and filesize > self.max_size:
+100 code = 'max_size'
+101 message = self.messages[code]
+102 params = {
+103 'size': filesizeformat(filesize),
+104 'allowed_size': filesizeformat(self.max_size)
+105 }
+106 raise ValidationError(message=message, code=code, params=params)
+ +108 elif filesize < self.min_size:
+109 code = 'min_size'
+110 message = self.messages[code]
+111 params = {
+112 'size': filesizeformat(filesize),
+113 'allowed_size': filesizeformat(self.min_size)
+114 }
+115 raise ValidationError(message=message, code=code, params=params)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1from rest_framework import mixins, generics
+2from workouts.mixins import CreateListModelMixin
+3from rest_framework import permissions
+4from users.serializers import (
+5 UserSerializer,
+6 OfferSerializer,
+7 AthleteFileSerializer,
+8 UserPutSerializer,
+9 UserGetSerializer,
+10)
+11from rest_framework.permissions import (
+12 IsAuthenticatedOrReadOnly,
+13)
+ +15from .models import Offer, AthleteFile, User
+16from django.contrib.auth import get_user_model
+17from django.db import connection
+18from django.db.models import Q
+19from rest_framework.parsers import MultiPartParser, FormParser
+20from .permissions import IsCurrentUser, IsAthlete, IsCoach
+21from workouts.permissions import IsOwner, IsReadOnly
+22from rest_framework.response import Response
+23from rest_framework import status
+ + +26class UserList(mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView):
+27 serializer_class = UserSerializer
+ +29 def get(self, request, *args, **kwargs):
+30 self.serializer_class = UserGetSerializer
+31 return self.list(request, *args, **kwargs)
+ +33 def post(self, request, *args, **kwargs):
+34 return self.create(request, *args, **kwargs)
+ +36 def get_queryset(self):
+37 qs = get_user_model().objects.all()
+ +39 if self.request.user:
+40 # Return the currently logged in user
+41 status = self.request.query_params.get("user", None)
+42 if status and status == "current":
+43 qs = get_user_model().objects.filter(pk=self.request.user.pk)
+ +45 return qs
+ + +48class UserDetail(
+49 mixins.RetrieveModelMixin,
+50 mixins.UpdateModelMixin,
+51 mixins.DestroyModelMixin,
+52 generics.GenericAPIView,
+53):
+54 lookup_field_options = ["pk", "username"]
+55 serializer_class = UserSerializer
+56 queryset = get_user_model().objects.all()
+57 permission_classes = [permissions.IsAuthenticated &
+58 (IsCurrentUser | IsReadOnly)]
+ +60 def get_object(self):
+61 for field in self.lookup_field_options:
+62 if field in self.kwargs:
+63 self.lookup_field = field
+64 break
+ +66 return super().get_object()
+ +68 def get(self, request, *args, **kwargs):
+69 pk = kwargs.get('pk')
+70 username = kwargs.get('username')
+ +72 if not pk and not username:
+73 return Response({'error': 'User ID or username not provided'}, status=400)
+ +75 if pk:
+76 instance = self.get_object()
+77 serializer = self.get_serializer(instance)
+78 return Response(serializer.data)
+ + +81 query = f"SELECT * FROM users_user WHERE username = '{username}'"
+82 with connection.cursor() as cursor:
+83 cursor.execute(query) # Executing the raw SQL query
+84 columns = [col[0] for col in cursor.description]
+85 rows = cursor.fetchall()
+ +87 if not rows:
+88 return Response({'error': 'User not found'}, status=404)
+ +90 instances = []
+91 for row in rows:
+92 if row:
+93 data = dict(zip(columns, row))
+94 instance = User(**data)
+95 instances.append(instance)
+ +97 if len(instances) == 1:
+98 serializer = self.get_serializer(
+99 instances[0], context={'request': request})
+100 else:
+101 serializer = self.get_serializer(
+102 instances, many=True, context={'request': request})
+ +104 return Response(serializer.data)
+ +106 def delete(self, request, *args, **kwargs):
+107 return self.destroy(request, *args, **kwargs)
+ +109 def put(self, request, *args, **kwargs):
+110 self.serializer_class = UserPutSerializer
+111 return self.update(request, *args, **kwargs)
+ +113 def patch(self, request, *args, **kwargs):
+114 return self.partial_update(request, *args, **kwargs)
+ + +117class OfferList(
+118 mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView
+119):
+120 permission_classes = [IsAuthenticatedOrReadOnly]
+121 serializer_class = OfferSerializer
+ +123 def get(self, request, *args, **kwargs):
+124 return self.list(request, *args, **kwargs)
+ +126 def post(self, request, *args, **kwargs):
+127 serializer = self.get_serializer(data=request.data)
+128 serializer.is_valid(raise_exception=True)
+129 self.perform_create(serializer)
+130 headers = self.get_success_headers(serializer.data)
+131 return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+ +133 def perform_create(self, serializer):
+134 serializer.save(owner=self.request.user)
+ +136 def get_queryset(self):
+137 qs = Offer.objects.none()
+ +139 if self.request.user:
+140 qs = Offer.objects.filter(
+141 Q(owner=self.request.user) | Q(recipient=self.request.user)
+142 ).distinct()
+ +144 # filtering by status (if provided)
+145 status = self.request.query_params.get("status", None)
+146 if status is not None:
+147 qs = qs.filter(status=status)
+148 return qs
+ + +151class OfferDetail(
+152 mixins.RetrieveModelMixin,
+153 mixins.DestroyModelMixin,
+154 generics.GenericAPIView,
+155):
+156 permission_classes = [IsAuthenticatedOrReadOnly]
+157 queryset = Offer.objects.all()
+158 serializer_class = OfferSerializer
+ +160 def get(self, request, *args, **kwargs):
+161 return self.retrieve(request, *args, **kwargs)
+ +163 def put(self, request, *args, **kwargs):
+164 id = kwargs.get('pk')
+165 offer = Offer.objects.get(pk=id)
+ +167 try:
+168 offer.status = request.data.get('status')
+ +170 # Add the sender of the offer to the recipients list of athletes, and add the recipient to the senders coach list
+171 if offer.status == 'a':
+172 if not offer.recipient.isCoach:
+173 return Response({'error': 'Recipient is not a coach'}, status=400)
+ +175 offer.recipient.athletes.add(offer.owner)
+176 offer.recipient.save()
+ +178 # Delete other pending offers from the same sender to the same recipient
+179 Offer.objects.filter(owner=offer.owner, recipient=offer.recipient, status='p').delete()
+ +181 except Exception as e:
+182 return Response({f'error: {e}'}, status=400)
+ +184 partial = kwargs.pop('partial', False)
+185 serializer = self.get_serializer(offer, data=request.data, partial=partial)
+186 serializer.is_valid(raise_exception=True)
+187 serializer.save()
+ +189 return Response(serializer.data)
+ +191 def patch(self, request, *args, **kwargs):
+192 return self.partial_update(request, *args, **kwargs)
+ +194 def delete(self, request, *args, **kwargs):
+195 return self.destroy(request, *args, **kwargs)
+ + +198class AthleteFileList(
+199 mixins.ListModelMixin,
+200 mixins.CreateModelMixin,
+201 CreateListModelMixin,
+202 generics.GenericAPIView,
+203):
+204 queryset = AthleteFile.objects.all()
+205 serializer_class = AthleteFileSerializer
+206 permission_classes = [permissions.IsAuthenticated & (IsAthlete | IsCoach)]
+207 parser_classes = [MultiPartParser, FormParser]
+ +209 def get(self, request, *args, **kwargs):
+210 return self.list(request, *args, **kwargs)
+ +212 def post(self, request, *args, **kwargs):
+213 return self.create(request, *args, **kwargs)
+ +215 def perform_create(self, serializer):
+216 serializer.save(owner=self.request.user)
+ +218 def get_queryset(self):
+219 user = self.request.user
+220 if user.isCoach:
+221 # Return files for athletes coached by this user
+222 return AthleteFile.objects.filter(athlete__coach=user)
+223 else:
+224 # Return files for the current athlete
+225 return AthleteFile.objects.filter(athlete=user)
+ + +228class AthleteFileDetail(
+229 mixins.RetrieveModelMixin,
+230 mixins.UpdateModelMixin,
+231 mixins.DestroyModelMixin,
+232 generics.GenericAPIView,
+233):
+234 queryset = AthleteFile.objects.all()
+235 serializer_class = AthleteFileSerializer
+236 permission_classes = [permissions.IsAuthenticated & (IsAthlete | IsOwner)]
+ +238 def get(self, request, *args, **kwargs):
+239 return self.retrieve(request, *args, **kwargs)
+ +241 def delete(self, request, *args, **kwargs):
+242 return self.destroy(request, *args, **kwargs)
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# Generated by Django 4.0.8 on 2024-07-29 11:55
+ +3from django.conf import settings
+4import django.contrib.auth.models
+5import django.contrib.auth.validators
+6from django.db import migrations, models
+7import django.db.models.deletion
+8import django.utils.timezone
+9import users.models
+ + +12class Migration(migrations.Migration):
+ +14 initial = True
+ +16 dependencies = [
+17 ('auth', '0012_alter_user_first_name_max_length'),
+18 ]
+ +20 operations = [
+21 migrations.CreateModel(
+22 name='User',
+23 fields=[
+24 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+25 ('password', models.CharField(max_length=128, verbose_name='password')),
+26 ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+27 ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+28 ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+29 ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+30 ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+31 ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+32 ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+33 ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+34 ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+35 ('coach', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='athletes', to=settings.AUTH_USER_MODEL)),
+36 ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+37 ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+38 ],
+39 options={
+40 'verbose_name': 'user',
+41 'verbose_name_plural': 'users',
+42 'abstract': False,
+43 },
+44 managers=[
+45 ('objects', django.contrib.auth.models.UserManager()),
+46 ],
+47 ),
+48 migrations.CreateModel(
+49 name='Offer',
+50 fields=[
+51 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+52 ('status', models.CharField(choices=[('a', 'Accepted'), ('p', 'Pending'), ('d', 'Declined')], default='p', max_length=8)),
+53 ('timestamp', models.DateTimeField(auto_now_add=True)),
+54 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_offers', to=settings.AUTH_USER_MODEL)),
+55 ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_offers', to=settings.AUTH_USER_MODEL)),
+56 ],
+57 ),
+58 migrations.CreateModel(
+59 name='AthleteFile',
+60 fields=[
+61 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+62 ('file', models.FileField(upload_to=users.models.athlete_directory_path)),
+63 ('athlete', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coach_files', to=settings.AUTH_USER_MODEL)),
+64 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='athlete_files', to=settings.AUTH_USER_MODEL)),
+65 ],
+66 ),
+67 ]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# Generated by Django 4.0.8 on 2024-08-19 10:10
+ +3from django.db import migrations, models
+ + +6class Migration(migrations.Migration):
+ +8 dependencies = [
+9 ('users', '0001_initial'),
+10 ]
+ +12 operations = [
+13 migrations.AddField(
+14 model_name='user',
+15 name='isCoach',
+16 field=models.BooleanField(default=False),
+17 ),
+18 ]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +1# Generated by Django 4.0.8 on 2024-08-19 14:22
+ +3from django.db import migrations, models
+ + +6class Migration(migrations.Migration):
+ +8 dependencies = [
+9 ('users', '0002_user_iscoach'),
+10 ]
+ +12 operations = [
+13 migrations.AddField(
+14 model_name='user',
+15 name='specialism',
+16 field=models.CharField(default='', max_length=1000),
+17 ),
+18 ]
++ « prev + ^ index + » next + + coverage.py v7.7.1, + created at 2025-04-03 19:25 +0200 +
+ +