diff --git a/codeforlife/kurono/avatar_state.py b/codeforlife/kurono/avatar_state.py index e61d59c..df88251 100644 --- a/codeforlife/kurono/avatar_state.py +++ b/codeforlife/kurono/avatar_state.py @@ -13,7 +13,12 @@ def create_avatar_state(avatar_state_json: Dict): if avatar_state_json.get("backpack"): # use namedtuple for artefacts to allow accessing fields by name avatar_state_dict["backpack"] = Backpack( - [namedtuple("Artefact", artefact.keys())(*artefact.values()) for artefact in avatar_state_json["backpack"]] + [ + namedtuple("Artefact", artefact.keys())(*artefact.values()) + for artefact in avatar_state_json["backpack"] + ] ) - return namedtuple("AvatarState", avatar_state_dict.keys())(*avatar_state_dict.values()) + return namedtuple("AvatarState", avatar_state_dict.keys())( + *avatar_state_dict.values() + ) diff --git a/codeforlife/kurono/backpack.py b/codeforlife/kurono/backpack.py index da863e8..c771e13 100644 --- a/codeforlife/kurono/backpack.py +++ b/codeforlife/kurono/backpack.py @@ -21,4 +21,11 @@ def find(self, artefact_type: str) -> int: Returns: The index of the artefact or -1 if it doesn't exist. """ - return next((i for i, artefact in enumerate(self) if artefact.type == artefact_type), -1) + return next( + ( + i + for i, artefact in enumerate(self) + if artefact.type == artefact_type + ), + -1, + ) diff --git a/codeforlife/kurono/pathfinding.py b/codeforlife/kurono/pathfinding.py index 39b6473..05e6a6b 100644 --- a/codeforlife/kurono/pathfinding.py +++ b/codeforlife/kurono/pathfinding.py @@ -16,7 +16,9 @@ def __init__(self, parent=None, cell=None): self.parent = parent self.cell = cell - self.g = 0 # g is the distance between the current node and the start node + self.g = ( + 0 # g is the distance between the current node and the start node + ) self.h = 0 # h is the heuristic - estimated distance from the current node to the end node self.f = 0 # f is the total cost of the node (g + h) @@ -107,13 +109,20 @@ def astar(world_map, start_cell, end_cell): # calculate the f, g, and h values child.g = current_node.g + 1 - child.h = ((child.location.x - end_node.location.x) ** 2) + ((child.location.y - end_node.location.y) ** 2) + child.h = ((child.location.x - end_node.location.x) ** 2) + ( + (child.location.y - end_node.location.y) ** 2 + ) child.f = child.g + child.h # check if it is already in the open list, and if this path to that square is better, # using G cost as the measure (lower G is better) open_nodes = [ - open_node for open_node in open_list if (child.location == open_node.location and child.g > open_node.g) + open_node + for open_node in open_list + if ( + child.location == open_node.location + and child.g > open_node.g + ) ] if len(open_nodes) > 0: continue diff --git a/codeforlife/kurono/utils.py b/codeforlife/kurono/utils.py index 71267f5..5933714 100644 --- a/codeforlife/kurono/utils.py +++ b/codeforlife/kurono/utils.py @@ -16,6 +16,8 @@ def __getitem__(self, i): return super().__getitem__(i) except IndexError: if len(self) == 0: - print("There aren't any nearby artefacts, you need to move closer!") + print( + "There aren't any nearby artefacts, you need to move closer!" + ) else: raise diff --git a/codeforlife/kurono/world_map.py b/codeforlife/kurono/world_map.py index 2a784ae..bafa56b 100644 --- a/codeforlife/kurono/world_map.py +++ b/codeforlife/kurono/world_map.py @@ -54,10 +54,15 @@ def habitable(self): return not (self.avatar or self.obstacle) def has_artefact(self): - return self.interactable is not None and self.interactable["type"] in ARTEFACT_TYPES + return ( + self.interactable is not None + and self.interactable["type"] in ARTEFACT_TYPES + ) def __repr__(self): - return "Cell({} a={} i={})".format(self.location, self.avatar, self.interactable) + return "Cell({} a={} i={})".format( + self.location, self.avatar, self.interactable + ) def __eq__(self, other): return self.location == other.location @@ -76,7 +81,10 @@ def generate_world_map_from_cells_data(cells: List[Cell]) -> "WorldMap": def generate_world_map_from_game_state(game_state) -> "WorldMap": cells: Dict[Location, Cell] = {} - for x in range(game_state["southWestCorner"]["x"], game_state["northEastCorner"]["x"] + 1): + for x in range( + game_state["southWestCorner"]["x"], + game_state["northEastCorner"]["x"] + 1, + ): for y in range( game_state["southWestCorner"]["y"], game_state["northEastCorner"]["y"] + 1, @@ -85,15 +93,21 @@ def generate_world_map_from_game_state(game_state) -> "WorldMap": cells[Location(x, y)] = cell for interactable in game_state["interactables"]: - location = Location(interactable["location"]["x"], interactable["location"]["y"]) + location = Location( + interactable["location"]["x"], interactable["location"]["y"] + ) cells[location].interactable = interactable for obstacle in game_state["obstacles"]: - location = Location(obstacle["location"]["x"], obstacle["location"]["y"]) + location = Location( + obstacle["location"]["x"], obstacle["location"]["y"] + ) cells[location].obstacle = obstacle for player in game_state["players"]: - location = Location(player["location"]["x"], player["location"]["y"]) + location = Location( + player["location"]["x"], player["location"]["y"] + ) cells[location].player = create_avatar_state(player) return WorldMap(cells) @@ -117,10 +131,18 @@ def interactable_cells(self): return [cell for cell in self.all_cells() if cell.interactable] def pickup_cells(self): - return [cell for cell in self.interactable_cells() if cell.interactable["type"] in PICKUP_TYPES] + return [ + cell + for cell in self.interactable_cells() + if cell.interactable["type"] in PICKUP_TYPES + ] def score_cells(self): - return [cell for cell in self.interactable_cells() if "score" == cell.interactable["type"]] + return [ + cell + for cell in self.interactable_cells() + if "score" == cell.interactable["type"] + ] def partially_fogged_cells(self): return [cell for cell in self.all_cells() if cell.partially_fogged] @@ -130,7 +152,11 @@ def is_visible(self, location): def get_cell(self, location): cell = self.cells[location] - assert cell.location == location, "location lookup mismatch: arg={}, found={}".format(location, cell.location) + assert ( + cell.location == location + ), "location lookup mismatch: arg={}, found={}".format( + location, cell.location + ) return cell def can_move_to(self, target_location): @@ -138,13 +164,19 @@ def can_move_to(self, target_location): cell = self.get_cell(target_location) except KeyError: return False - return getattr(cell, "habitable", False) and not getattr(cell, "avatar", False) + return getattr(cell, "habitable", False) and not getattr( + cell, "avatar", False + ) def _scan_artefacts(self, start_location, radius): # get artefacts from starting location within the radius artefacts = [] - for x in range(start_location.x - radius, start_location.x + radius + 1): - for y in range(start_location.y - radius, start_location.y + radius + 1): + for x in range( + start_location.x - radius, start_location.x + radius + 1 + ): + for y in range( + start_location.y - radius, start_location.y + radius + 1 + ): try: cell = self.get_cell(Location(x, y)) except KeyError: @@ -153,7 +185,9 @@ def _scan_artefacts(self, start_location, radius): artefacts.append(cell) return artefacts - def scan_nearby(self, avatar_location, radius=SCAN_RADIUS) -> NearbyArtefactsList[dict]: + def scan_nearby( + self, avatar_location, radius=SCAN_RADIUS + ) -> NearbyArtefactsList[dict]: """ From the given location point search the given radius for artefacts. Returns list of nearest artefacts (artefact/interactable represented as dict). diff --git a/codeforlife/mixins/__init__.py b/codeforlife/mixins/__init__.py new file mode 100644 index 0000000..800655b --- /dev/null +++ b/codeforlife/mixins/__init__.py @@ -0,0 +1 @@ +from .cron_mixin import CronMixin diff --git a/codeforlife/mixins/cron_mixin.py b/codeforlife/mixins/cron_mixin.py new file mode 100644 index 0000000..f6f5c6c --- /dev/null +++ b/codeforlife/mixins/cron_mixin.py @@ -0,0 +1,17 @@ +from rest_framework.request import Request +from rest_framework.response import Response + +from ..permissions import IsCronRequestFromGoogle + + +class CronMixin: + """ + A cron job on Google's AppEngine. + https://cloud.google.com/appengine/docs/flexible/scheduling-jobs-with-cron-yaml + """ + + http_method_names = ["get"] + permission_classes = [IsCronRequestFromGoogle] + + def get(self, request: Request) -> Response: + raise NotImplementedError() diff --git a/codeforlife/permissions/__init__.py b/codeforlife/permissions/__init__.py new file mode 100644 index 0000000..cc21e16 --- /dev/null +++ b/codeforlife/permissions/__init__.py @@ -0,0 +1 @@ +from .is_cron_request_from_google import IsCronRequestFromGoogle diff --git a/codeforlife/permissions/is_cron_request_from_google.py b/codeforlife/permissions/is_cron_request_from_google.py new file mode 100644 index 0000000..cf98e5c --- /dev/null +++ b/codeforlife/permissions/is_cron_request_from_google.py @@ -0,0 +1,18 @@ +from django.conf import settings +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import View + + +class IsCronRequestFromGoogle(BasePermission): + """ + Validate that requests to your cron URLs are coming from App Engine and not + from another source. + https://cloud.google.com/appengine/docs/flexible/scheduling-jobs-with-cron-yaml#securing_urls_for_cron + """ + + def has_permission(self, request: Request, view: View): + return ( + settings.DEBUG + or request.META.get("HTTP_X_APPENGINE_CRON") == "true" + ) diff --git a/codeforlife/user/admin.py b/codeforlife/user/admin.py index 8fee5c4..c373800 100644 --- a/codeforlife/user/admin.py +++ b/codeforlife/user/admin.py @@ -20,14 +20,35 @@ class CustomUserAdmin(UserAdmin): fieldsets = ( (None, {"fields": ("username", "email", "password")}), - ("Permissions", {"fields": ("is_staff", "is_active", "is_superuser", "groups", "user_permissions")}), + ( + "Permissions", + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), ("Dates", {"fields": ("last_login", "date_joined")}), ) add_fieldsets = ( ( None, - {"classes": ("wide",), "fields": ("username", "email", "password1", "password2", "is_staff", "is_active")}, + { + "classes": ("wide",), + "fields": ( + "username", + "email", + "password1", + "password2", + "is_staff", + "is_active", + ), + }, ), ) diff --git a/codeforlife/user/models/classroom.py b/codeforlife/user/models/classroom.py index 7d1c969..a743a02 100644 --- a/codeforlife/user/models/classroom.py +++ b/codeforlife/user/models/classroom.py @@ -29,7 +29,9 @@ def get_queryset(self): class Class(models.Model): name = models.CharField(max_length=200) - teacher = models.ForeignKey(Teacher, related_name="class_teacher", on_delete=models.CASCADE) + teacher = models.ForeignKey( + Teacher, related_name="class_teacher", on_delete=models.CASCADE + ) access_code = models.CharField(max_length=5, null=True) classmates_data_viewable = models.BooleanField(default=False) always_accept_requests = models.BooleanField(default=False) @@ -37,7 +39,11 @@ class Class(models.Model): creation_time = models.DateTimeField(default=timezone.now, null=True) is_active = models.BooleanField(default=True) created_by = models.ForeignKey( - Teacher, null=True, blank=True, related_name="created_classes", on_delete=models.SET_NULL + Teacher, + null=True, + blank=True, + related_name="created_classes", + on_delete=models.SET_NULL, ) objects = ClassModelManager() @@ -49,7 +55,9 @@ def __str__(self): def active_game(self): games = self.game_set.filter(game_class=self, is_archived=False) if len(games) >= 1: - assert len(games) == 1 # there should NOT be more than one active game + assert ( + len(games) == 1 + ) # there should NOT be more than one active game return games[0] return None @@ -59,8 +67,13 @@ def has_students(self): def get_requests_message(self): if self.always_accept_requests: - external_requests_message = "This class is currently set to always accept requests." - elif self.accept_requests_until is not None and (self.accept_requests_until - timezone.now()) >= timedelta(): + external_requests_message = ( + "This class is currently set to always accept requests." + ) + elif ( + self.accept_requests_until is not None + and (self.accept_requests_until - timezone.now()) >= timedelta() + ): external_requests_message = ( "This class is accepting external requests until " + self.accept_requests_until.strftime("%d-%m-%Y %H:%M") @@ -68,7 +81,9 @@ def get_requests_message(self): + timezone.get_current_timezone_name() ) else: - external_requests_message = "This class is not currently accepting external requests." + external_requests_message = ( + "This class is not currently accepting external requests." + ) return external_requests_message diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 8712855..f58f7b2 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -19,7 +19,9 @@ class JoinReleaseStudent(models.Model): JOIN = "join" RELEASE = "release" - student = models.ForeignKey(Student, related_name="student", on_delete=models.CASCADE) + student = models.ForeignKey( + Student, related_name="student", on_delete=models.CASCADE + ) # either "release" or "join" action_type = models.CharField(max_length=64) action_time = models.DateTimeField(default=timezone.now) diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 231ad7c..5e41845 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -35,7 +35,11 @@ def classes(self): def admins(self): teachers = self.school_teacher.all() - return [teacher for teacher in teachers if teacher.is_admin] if teachers else None + return ( + [teacher for teacher in teachers if teacher.is_admin] + if teachers + else None + ) def anonymise(self): self.name = uuid4().hex diff --git a/codeforlife/user/models/session.py b/codeforlife/user/models/session.py index 8fe63da..31b22f3 100644 --- a/codeforlife/user/models/session.py +++ b/codeforlife/user/models/session.py @@ -11,7 +11,9 @@ class UserSession(models.Model): login_time = models.DateTimeField(default=timezone.now) school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) - login_type = models.CharField(max_length=100, null=True) # for student login + login_type = models.CharField( + max_length=100, null=True + ) # for student login def __str__(self): return f"{self.user} login: {self.login_time} type: {self.login_type}" diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 6af701b..acead87 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -14,23 +14,47 @@ def get_random_username(self): return random_username def schoolFactory(self, klass, name, password, login_id=None): - user = User.objects.create_user(username=self.get_random_username(), password=password, first_name=name) + user = User.objects.create_user( + username=self.get_random_username(), + password=password, + first_name=name, + ) - return Student.objects.create(class_field=klass, user=user, login_id=login_id) + return Student.objects.create( + class_field=klass, user=user, login_id=login_id + ) def independentStudentFactory(self, name, email, password): - user = User.objects.create_user(username=email, email=email, password=password, first_name=name) + user = User.objects.create_user( + username=email, email=email, password=password, first_name=name + ) return Student.objects.create(user=user) class Student(models.Model): - class_field = models.ForeignKey(Class, related_name="students", null=True, blank=True, on_delete=models.CASCADE) + class_field = models.ForeignKey( + Class, + related_name="students", + null=True, + blank=True, + on_delete=models.CASCADE, + ) # hashed uuid used for the unique direct login url login_id = models.CharField(max_length=64, null=True) - user = models.OneToOneField(User, related_name="student", null=True, blank=True, on_delete=models.CASCADE) + user = models.OneToOneField( + User, + related_name="student", + null=True, + blank=True, + on_delete=models.CASCADE, + ) pending_class_request = models.ForeignKey( - Class, related_name="class_request", null=True, blank=True, on_delete=models.SET_NULL + Class, + related_name="class_request", + null=True, + blank=True, + on_delete=models.SET_NULL, ) blocked_time = models.DateTimeField(null=True, blank=True) diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 9084123..1d23981 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -7,7 +7,11 @@ class TeacherModelManager(models.Manager): def factory(self, first_name, last_name, email, password): user = User.objects.create_user( - username=email, email=email, password=password, first_name=first_name, last_name=last_name + username=email, + email=email, + password=password, + first_name=first_name, + last_name=last_name, ) return Teacher.objects.create(user=user) @@ -18,12 +22,28 @@ def get_queryset(self): class Teacher(models.Model): - user = models.OneToOneField(User, related_name="teacher", null=True, blank=True, on_delete=models.CASCADE) - school = models.ForeignKey(School, related_name="school_teacher", null=True, blank=True, on_delete=models.SET_NULL) + user = models.OneToOneField( + User, + related_name="teacher", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + school = models.ForeignKey( + School, + related_name="school_teacher", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) is_admin = models.BooleanField(default=False) blocked_time = models.DateTimeField(null=True, blank=True) invited_by = models.ForeignKey( - "self", related_name="invited_teachers", null=True, blank=True, on_delete=models.SET_NULL + "self", + related_name="invited_teachers", + null=True, + blank=True, + on_delete=models.SET_NULL, ) objects = TeacherModelManager() @@ -31,7 +51,10 @@ class Teacher(models.Model): def teaches(self, userprofile): if hasattr(userprofile, "student"): student = userprofile.student - return not student.is_independent() and student.class_field.teacher == self + return ( + not student.is_independent() + and student.class_field.teacher == self + ) def has_school(self): return self.school is not (None or "") diff --git a/codeforlife/user/models/teacher_invitation.py b/codeforlife/user/models/teacher_invitation.py index 160641b..d6c2e9c 100644 --- a/codeforlife/user/models/teacher_invitation.py +++ b/codeforlife/user/models/teacher_invitation.py @@ -15,8 +15,18 @@ def get_queryset(self): class SchoolTeacherInvitation(models.Model): token = models.CharField(max_length=32) - school = models.ForeignKey(School, related_name="teacher_invitations", null=True, on_delete=models.SET_NULL) - from_teacher = models.ForeignKey(Teacher, related_name="school_invitations", null=True, on_delete=models.SET_NULL) + school = models.ForeignKey( + School, + related_name="teacher_invitations", + null=True, + on_delete=models.SET_NULL, + ) + from_teacher = models.ForeignKey( + Teacher, + related_name="school_invitations", + null=True, + on_delete=models.SET_NULL, + ) creation_time = models.DateTimeField(default=timezone.now, null=True) is_active = models.BooleanField(default=True) diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index 73ff2d2..29c6c61 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -11,8 +11,12 @@ class UserManager(AbstractUserManager): def create_user(self, username, email=None, password=None, **extra_fields): return super().create_user(username, email, password, **extra_fields) - def create_superuser(self, username, email=None, password=None, **extra_fields): - return super().create_superuser(username, email, password, **extra_fields) + def create_superuser( + self, username, email=None, password=None, **extra_fields + ): + return super().create_superuser( + username, email, password, **extra_fields + ) class User(AbstractUser): diff --git a/manage.py b/manage.py index c424449..61dd1ab 100644 --- a/manage.py +++ b/manage.py @@ -9,7 +9,9 @@ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-!s&7h44ae3y6+_*o*i)zf7#3gs1)mk%g@1h#2xzk1c&o2&y4$o" +SECRET_KEY = ( + "django-insecure-!s&7h44ae3y6+_*o*i)zf7#3gs1)mk%g@1h#2xzk1c&o2&y4$o" +) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/pyproject.toml b/pyproject.toml index 408c6bc..ba73e72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ upload_to_repository = false upload_to_release = true [tool.black] -line-length = 120 +line-length = 80 extend-exclude = "^/codeforlife/user/migrations/" [tool.pytest.ini_options] diff --git a/setup.py b/setup.py index 3a340bb..29e9673 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,9 @@ data_files, root_dir = [], os.path.dirname(__file__) for dir_path, dir_names, file_names in os.walk(DATA_DIR): rel_data_dir = os.path.relpath(dir_path, root_dir) - data_files += [os.path.join(rel_data_dir, file_name) for file_name in file_names] + data_files += [ + os.path.join(rel_data_dir, file_name) for file_name in file_names + ] setup( name="codeforlife",