diff --git a/.env.example b/.env.example index 3eba598..b7a8b1d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ - SECRET_KEY = '' APP_SCRIPT_URL = 'https://script.google.com/macros/s/oooooooooooooooooooooooooo/exec' DATABASE_URL = '' -DJANGO_DATABASE = 'default' \ No newline at end of file +DJANGO_DATABASE = 'default' +DEBUG = 'False' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5537113..8d55343 100644 --- a/.gitignore +++ b/.gitignore @@ -291,4 +291,5 @@ fabric.properties db-data -db \ No newline at end of file +db +logs/ diff --git a/HeroHours/admin.py b/HeroHours/admin.py index 8a8fb89..0473c8d 100644 --- a/HeroHours/admin.py +++ b/HeroHours/admin.py @@ -1,26 +1,29 @@ +# Standard library imports import csv import json -from datetime import datetime from types import SimpleNamespace +# Third-party imports import django.contrib.auth.models as authModels from django.contrib import admin from django.contrib.admin import SimpleListFilter +from django.contrib.admin.utils import unquote from django.contrib.auth.decorators import user_passes_test -from django.core.exceptions import PermissionDenied, BadRequest +from django.core.exceptions import PermissionDenied +from django.db.models import DurationField, ExpressionWrapper, F from django.forms import model_to_dict from django.http import HttpResponse from django.shortcuts import redirect, render +from django.template.response import TemplateResponse from django.utils import timezone -from django.utils.translation import gettext_lazy as _ from django.utils.text import capfirst -from HeroHours.forms import CustomActionForm -from . import models -from .models import Users, ActivityLog +from django.utils.translation import gettext_lazy as _ from rest_framework.authtoken.admin import TokenAdmin -from django.contrib.admin.utils import (unquote) -from django.contrib.admin.options import get_content_type_for_model -from django.template.response import TemplateResponse + +# Local imports +from . import models +from .forms import CustomActionForm +from .models import ActivityLog, Users # Register your models here. @@ -31,18 +34,21 @@ def check_out(modeladmin, request, queryset): getall = queryset.filter(Checked_In=True) updated_users = [] updated_log = [] + right_now = timezone.now() for user in getall: lognew = models.ActivityLog( user_id=user.User_ID, + entered=user.User_ID, operation='Check Out', - status='Success', # Initial status + status='Success', ) user.Checked_In = False - user.Total_Hours = ( - datetime.combine(datetime.today(), user.Total_Hours) + (timezone.now() - user.Last_In)).time() - #print((timezone.now() - user.Last_In).total_seconds()) - user.Total_Seconds += round((timezone.now() - user.Last_In).total_seconds()) - user.Last_Out = timezone.now() + if not user.Last_In: + user.Last_In = right_now + user.Total_Hours = ExpressionWrapper(F('Total_Hours') + (right_now - user.Last_In), + output_field=DurationField()) + user.Total_Seconds = F('Total_Seconds') + round((right_now - user.Last_In).total_seconds()) + user.Last_Out = right_now updated_log.append(lognew) updated_users.append(user) models.Users.objects.bulk_update(updated_users, ["Checked_In", "Total_Hours", "Total_Seconds", "Last_Out"]) @@ -53,18 +59,20 @@ def check_out(modeladmin, request, queryset): def check_in(modeladmin, request, queryset): updated_users = [] updated_log = [] + right_now = timezone.now() getall = queryset.filter(Checked_In=False) for user in getall: lognew = models.ActivityLog( user_id=user.User_ID, + entered=user.User_ID, operation='Check In', - status='Success', # Initial status + status='Success', ) user.Checked_In = True - user.Last_In = timezone.now() + user.Last_In = right_now updated_log.append(lognew) updated_users.append(user) - models.Users.objects.bulk_update(updated_users, ["Checked_In", "Total_Hours", "Total_Seconds", "Last_Out"]) + models.Users.objects.bulk_update(updated_users, ["Checked_In", "Last_In"]) models.ActivityLog.objects.bulk_create(updated_log) @admin.action(description="Reset Members") @@ -74,8 +82,9 @@ def reset(modeladmin, request, queryset): for user in queryset: lognew = models.ActivityLog( user_id=user.User_ID, + entered=user.User_ID, operation='Reset', - status='Success', # Initial status + status='Success', ) user.Total_Seconds = 0 user.Total_Hours = '0:00:00' @@ -86,6 +95,7 @@ def reset(modeladmin, request, queryset): updated_log.append( models.ActivityLog( user_id=user.User_ID, + entered=user.User_ID, operation='Check Out', status='Success', ) @@ -95,8 +105,8 @@ def reset(modeladmin, request, queryset): models.Users.objects.bulk_update(updated_users, ["Checked_In", "Total_Hours", "Total_Seconds", "Last_Out", "Last_In"]) models.ActivityLog.objects.bulk_create(updated_log) +@admin.action(description="Create a Staff User") def create_staff_user_action(modeladmin, request, queryset): - print(request) selected_user = queryset.first() userdata = model_to_dict(selected_user) @@ -105,9 +115,6 @@ def create_staff_user_action(modeladmin, request, queryset): return render(request, 'admin/custom_action_form.html', {'form': form}) -create_staff_user_action.short_description = "Create a Staff User" - - class TotalHoursFilter(SimpleListFilter): title = _('Total Hours') # Display title in the admin filter sidebar parameter_name = 'total_hours' # URL parameter @@ -121,7 +128,7 @@ def lookups(self, request, model_admin): ('25hours', _('Less than 25 hours')), ('o25hours', _('Over 25 hours')), - ('o50hours',_('Over 50 hours')) + ('o50hours', _('Over 50 hours')) ] def queryset(self, request, queryset): @@ -152,12 +159,12 @@ def export_as_csv(self, request, queryset): field_names = [field.name for field in meta.fields] response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta) + response['Content-Disposition'] = f'attachment; filename={meta}.csv' writer = csv.writer(response) writer.writerow(field_names) for obj in queryset: - row = writer.writerow([getattr(obj, field) for field in field_names]) + writer.writerow([getattr(obj, field) for field in field_names]) return response @@ -170,12 +177,10 @@ class MemberAdmin(admin.ModelAdmin): search_fields = ['User_ID', 'Last_Name', 'First_Name'] list_filter = ['Checked_In', TotalHoursFilter] + @admin.display(description="Total Hours", ordering="Total_Seconds") def display_total_hours(self, obj): return obj.get_total_hours() - display_total_hours.short_description = "Total Hours" - display_total_hours.admin_order_field = "Total_Seconds" - """ Custom history view, modified from Django source @@ -272,31 +277,28 @@ class ActivityAdminView(admin.ModelAdmin): search_fields = ['timestamp'] actions = [export_as_csv] + @admin.display(description='Date') def get_date_only(self, obj): return timezone.localtime(obj.timestamp).date() + + @admin.display(description='Entered') def get_entered_data(self, obj): return obj.entered - get_entered_data.short_description = 'Entered' - - - get_date_only.short_description = 'Date' + @admin.display(description='Name') def get_name(self, obj): if obj.user: return f'{obj.user.First_Name} {obj.user.Last_Name}' - return 'None' - get_name.short_description = 'Name' + return 'None' + @admin.display(description='Status') def get_status(self, obj): return obj.status - get_status.short_description = 'Status' - + @admin.display(description='Operation') def get_op(self, obj): return obj.operation - get_op.short_description = 'Operation' - def is_superuser(user): return user.is_superuser @@ -306,7 +308,6 @@ def is_superuser(user): def add_user(request): form_data_dict = request.POST.dict() form_data = SimpleNamespace(**form_data_dict) - print(form_data) username = form_data.username password = form_data.password hidden_data = json.loads(form_data.hidden_data) @@ -314,9 +315,7 @@ def add_user(request): lname = hidden_data['Last_Name'] group_name = form_data.group_name - if authModels.User.objects.filter(username=username).exists(): - print('User already exists') - else: + if not authModels.User.objects.filter(username=username).exists(): user = authModels.User.objects.create_user(username=username, first_name=fname, last_name=lname) @@ -325,11 +324,8 @@ def add_user(request): user.save() group = authModels.Group.objects.get(name=group_name) - print(group) user.groups.add(group) - print('nicely done') - return redirect('/admin/') diff --git a/HeroHours/apps.py b/HeroHours/apps.py index 512a6f7..b87084f 100644 --- a/HeroHours/apps.py +++ b/HeroHours/apps.py @@ -3,4 +3,4 @@ class HeroHoursConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'HeroHours' + name = 'HeroHours' \ No newline at end of file diff --git a/HeroHours/consumers.py b/HeroHours/consumers.py index 9af4f8b..fc23c1b 100644 --- a/HeroHours/consumers.py +++ b/HeroHours/consumers.py @@ -1,16 +1,9 @@ -import json - -from channels.generic.websocket import AsyncWebsocketConsumer -from django.contrib.auth.decorators import permission_required from djangochannelsrestframework.decorators import action from djangochannelsrestframework.observer import model_observer from djangochannelsrestframework.observer.generics import ObserverModelInstanceMixin -from djangochannelsrestframework.permissions import WrappedDRFPermission, IsAuthenticated +from djangochannelsrestframework.permissions import IsAuthenticated from rest_framework import serializers -from rest_framework.permissions import DjangoModelPermissions -from . import models -from djangochannelsrestframework import permissions from djangochannelsrestframework.generics import GenericAsyncAPIConsumer from djangochannelsrestframework.mixins import ( ListModelMixin, RetrieveModelMixin, @@ -21,10 +14,19 @@ class MemberSerializer(serializers.ModelSerializer): + """Serializer for Users model used in WebSocket communications.""" class Meta: model = Users fields = ['User_ID', 'First_Name', 'Last_Name', 'Checked_In', 'Total_Seconds', 'Last_In', 'Last_Out'] + + class LiveConsumer(ObserverModelInstanceMixin, RetrieveModelMixin, ListModelMixin, GenericAsyncAPIConsumer): + """ + WebSocket consumer for real-time member updates. + + Provides live updates when members check in/out via the update_activity observer. + Requires authentication to connect. + """ queryset = Users.objects.all().order_by('Last_Name','First_Name') serializer_class = MemberSerializer permission_classes = [IsAuthenticated] @@ -37,11 +39,31 @@ async def update_activity( subscribing_request_ids=[], **kwargs ): + """ + Observer that sends updates when Users model changes. + + Args: + message: Serialized user data + observer: Observer instance + subscribing_request_ids: List of request IDs subscribed to updates + """ await self.send_json({'data':message.data, 'request_ids':subscribing_request_ids}) @update_activity.serializer def update_activity(self, instance: Users, action, **kwargs) -> MemberSerializer: - """This will return the comment serializer""" + """ + Serializer for the update_activity observer. + + Refreshes instance from database if any field contains F() expressions + to ensure accurate data is sent to subscribers. + + Args: + instance: Users model instance that changed + action: Type of change (create, update, delete) + + Returns: + MemberSerializer: Serialized user data + """ for field in instance._meta.fields: if isinstance(getattr(instance, field.name), BaseExpression): instance.refresh_from_db() @@ -50,4 +72,10 @@ def update_activity(self, instance: Users, action, **kwargs) -> MemberSerializer @action() async def subscribe_all(self, request_id, **kwargs): - await self.update_activity.subscribe(request_id=request_id) \ No newline at end of file + """ + Action to subscribe to all Users model updates. + + Args: + request_id: Unique request identifier for this subscription + """ + await self.update_activity.subscribe(request_id=request_id) diff --git a/HeroHours/forms.py b/HeroHours/forms.py index 5455db6..1f6e47c 100644 --- a/HeroHours/forms.py +++ b/HeroHours/forms.py @@ -3,7 +3,6 @@ class CustomActionForm(forms.Form): - print("running form") username = forms.CharField(label='Username', max_length=100) password = forms.CharField(label='Password', widget=forms.PasswordInput) group_name = forms.ChoiceField(label='Group Name', required=True) @@ -16,4 +15,4 @@ def __init__(self, *args, **kwargs): self.fields['group_name'].choices = group_choices # Set the choices for the field - hidden_data = forms.CharField(widget=forms.HiddenInput(), required=False) + hidden_data = forms.CharField(widget=forms.HiddenInput(), required=False) \ No newline at end of file diff --git a/HeroHours/management/commands/bulk.py b/HeroHours/management/commands/bulk.py index 4e02bb4..441ff76 100644 --- a/HeroHours/management/commands/bulk.py +++ b/HeroHours/management/commands/bulk.py @@ -1,23 +1,21 @@ -import csv -import datetime +from datetime import datetime from django.core.management.base import BaseCommand -from HeroHours.models import Users -from HeroHours.views import handle_bulk_updates # Replace 'yourapp' with your actual app name +from HeroHours.views import handle_bulk_updates class Command(BaseCommand): - help = 'Run a comand at a specific time' + help = 'Run a command at a specific time' def add_arguments(self, parser): parser.add_argument('userID', type=str, help='The userID/Command to run') - parser.add_argument('time', type=str, help='The time to use') + parser.add_argument('time', type=str, help='The time to use (format: YYYY MM DD HH MM)') def handle(self, *args, **options): userID = options["userID"] time_string = options["time"].split() - year = time_string[0] - month = time_string[1] - day = time_string[2] - hour = time_string[3] - minute = time_string[4] - handle_bulk_updates(user_id=userID,time=datetime(year,month,day,hour,minute)) + year = int(time_string[0]) + month = int(time_string[1]) + day = int(time_string[2]) + hour = int(time_string[3]) + minute = int(time_string[4]) + handle_bulk_updates(user_id=userID, at_time=datetime(year, month, day, hour, minute)) \ No newline at end of file diff --git a/HeroHours/management/commands/import_users.py b/HeroHours/management/commands/import_users.py index 0211be7..fa37f64 100644 --- a/HeroHours/management/commands/import_users.py +++ b/HeroHours/management/commands/import_users.py @@ -27,4 +27,4 @@ def handle(self, *args, **options): # Bulk create users in the database Users.objects.bulk_create(users) - self.stdout.write(self.style.SUCCESS('Successfully imported users')) + self.stdout.write(self.style.SUCCESS('Successfully imported users')) \ No newline at end of file diff --git a/HeroHours/migrations/0001_initial.py b/HeroHours/migrations/0001_initial.py index 3395912..1c6dfd9 100644 --- a/HeroHours/migrations/0001_initial.py +++ b/HeroHours/migrations/0001_initial.py @@ -25,4 +25,4 @@ class Migration(migrations.Migration): 'db_table': 'Users', }, ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0002_alter_users_total_seconds.py b/HeroHours/migrations/0002_alter_users_total_seconds.py index 531bce1..4a42f4e 100644 --- a/HeroHours/migrations/0002_alter_users_total_seconds.py +++ b/HeroHours/migrations/0002_alter_users_total_seconds.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Total_Seconds', field=models.FloatField(default=0), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0003_users_last_in_users_last_out.py b/HeroHours/migrations/0003_users_last_in_users_last_out.py index e2419bd..42b80d0 100644 --- a/HeroHours/migrations/0003_users_last_in_users_last_out.py +++ b/HeroHours/migrations/0003_users_last_in_users_last_out.py @@ -20,4 +20,4 @@ class Migration(migrations.Migration): name='Last_Out', field=models.DateTimeField(null=True), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0004_alter_users_last_in_alter_users_last_out.py b/HeroHours/migrations/0004_alter_users_last_in_alter_users_last_out.py index 69e4b20..31acddb 100644 --- a/HeroHours/migrations/0004_alter_users_last_in_alter_users_last_out.py +++ b/HeroHours/migrations/0004_alter_users_last_in_alter_users_last_out.py @@ -20,4 +20,4 @@ class Migration(migrations.Migration): name='Last_Out', field=models.DateTimeField(null=True), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0005_alter_users_total_hours.py b/HeroHours/migrations/0005_alter_users_total_hours.py index ae3b0c0..2152b55 100644 --- a/HeroHours/migrations/0005_alter_users_total_hours.py +++ b/HeroHours/migrations/0005_alter_users_total_hours.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Total_Hours', field=models.TimeField(), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0006_alter_users_total_hours.py b/HeroHours/migrations/0006_alter_users_total_hours.py index 05df508..6264c01 100644 --- a/HeroHours/migrations/0006_alter_users_total_hours.py +++ b/HeroHours/migrations/0006_alter_users_total_hours.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Total_Hours', field=models.TimeField(), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0007_alter_users_total_hours.py b/HeroHours/migrations/0007_alter_users_total_hours.py index db2795a..b42fcef 100644 --- a/HeroHours/migrations/0007_alter_users_total_hours.py +++ b/HeroHours/migrations/0007_alter_users_total_hours.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Total_Hours', field=models.TimeField(), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0008_activitylog_alter_users_total_hours.py b/HeroHours/migrations/0008_activitylog_alter_users_total_hours.py index 3cc179b..c78f55d 100644 --- a/HeroHours/migrations/0008_activitylog_alter_users_total_hours.py +++ b/HeroHours/migrations/0008_activitylog_alter_users_total_hours.py @@ -29,4 +29,4 @@ class Migration(migrations.Migration): name='Total_Hours', field=models.TimeField(), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0009_alter_activitylog_message.py b/HeroHours/migrations/0009_alter_activitylog_message.py index f986993..0d2b3cc 100644 --- a/HeroHours/migrations/0009_alter_activitylog_message.py +++ b/HeroHours/migrations/0009_alter_activitylog_message.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='message', field=models.TextField(default=''), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0010_alter_activitylog_options.py b/HeroHours/migrations/0010_alter_activitylog_options.py index 0ef05c5..ea20c54 100644 --- a/HeroHours/migrations/0010_alter_activitylog_options.py +++ b/HeroHours/migrations/0010_alter_activitylog_options.py @@ -14,4 +14,4 @@ class Migration(migrations.Migration): name='activitylog', options={'ordering': ['-id']}, ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0011_alter_activitylog_options_alter_activitylog_status.py b/HeroHours/migrations/0011_alter_activitylog_options_alter_activitylog_status.py index b19602b..b28cabc 100644 --- a/HeroHours/migrations/0011_alter_activitylog_options_alter_activitylog_status.py +++ b/HeroHours/migrations/0011_alter_activitylog_options_alter_activitylog_status.py @@ -19,4 +19,4 @@ class Migration(migrations.Migration): name='status', field=models.CharField(choices=[('success', 'Success'), ('error', 'Error')], max_length=20), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0012_alter_activitylog_options_alter_users_options_and_more.py b/HeroHours/migrations/0012_alter_activitylog_options_alter_users_options_and_more.py index da56584..49bc67d 100644 --- a/HeroHours/migrations/0012_alter_activitylog_options_alter_users_options_and_more.py +++ b/HeroHours/migrations/0012_alter_activitylog_options_alter_users_options_and_more.py @@ -28,4 +28,4 @@ class Migration(migrations.Migration): name='status', field=models.CharField(choices=[('success', 'Success'), ('error', 'Error'), ('user not found', 'User Not Found')], max_length=20), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0013_alter_activitylog_options.py b/HeroHours/migrations/0013_alter_activitylog_options.py index 96aef25..c3abd9f 100644 --- a/HeroHours/migrations/0013_alter_activitylog_options.py +++ b/HeroHours/migrations/0013_alter_activitylog_options.py @@ -14,4 +14,4 @@ class Migration(migrations.Migration): name='activitylog', options={'ordering': ['-timestamp']}, ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0014_users_is_active_alter_activitylog_status.py b/HeroHours/migrations/0014_users_is_active_alter_activitylog_status.py index a315497..cd93ca1 100644 --- a/HeroHours/migrations/0014_users_is_active_alter_activitylog_status.py +++ b/HeroHours/migrations/0014_users_is_active_alter_activitylog_status.py @@ -20,4 +20,4 @@ class Migration(migrations.Migration): name='status', field=models.CharField(choices=[('success', 'Success'), ('error', 'Error'), ('user not found', 'User Not Found'), ('inactive user', 'Inactive User')], max_length=20), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0015_alter_users_last_in.py b/HeroHours/migrations/0015_alter_users_last_in.py index d4adff2..7d5abff 100644 --- a/HeroHours/migrations/0015_alter_users_last_in.py +++ b/HeroHours/migrations/0015_alter_users_last_in.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Last_In', field=models.DateTimeField(auto_now_add=True, null=True), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0016_alter_users_total_hours.py b/HeroHours/migrations/0016_alter_users_total_hours.py index 331d137..f15560e 100644 --- a/HeroHours/migrations/0016_alter_users_total_hours.py +++ b/HeroHours/migrations/0016_alter_users_total_hours.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Total_Hours', field=models.DurationField(), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0017_activitylog_entered.py b/HeroHours/migrations/0017_activitylog_entered.py index aa3bd14..fe10cfa 100644 --- a/HeroHours/migrations/0017_activitylog_entered.py +++ b/HeroHours/migrations/0017_activitylog_entered.py @@ -22,4 +22,4 @@ class Migration(migrations.Migration): preserve_default=False, ), migrations.RunPython(code=copy_field), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0019_alter_activitylog_operation.py b/HeroHours/migrations/0019_alter_activitylog_operation.py index 51f26bb..bd63518 100644 --- a/HeroHours/migrations/0019_alter_activitylog_operation.py +++ b/HeroHours/migrations/0019_alter_activitylog_operation.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='operation', field=models.CharField(choices=[('checkIn', 'Check In'), ('checkOut', 'Check Out'), ('none', 'None'), ('autoCheckOut', 'Auto Check Out')], max_length=14), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0020_alter_users_last_in.py b/HeroHours/migrations/0020_alter_users_last_in.py index 8618cdf..ea44dfb 100644 --- a/HeroHours/migrations/0020_alter_users_last_in.py +++ b/HeroHours/migrations/0020_alter_users_last_in.py @@ -15,4 +15,4 @@ class Migration(migrations.Migration): name='Last_In', field=models.DateTimeField(null=True), ), - ] + ] \ No newline at end of file diff --git a/HeroHours/migrations/0021_alter_activitylog_operation_alter_activitylog_status.py b/HeroHours/migrations/0021_alter_activitylog_operation_alter_activitylog_status.py new file mode 100644 index 0000000..ac09211 --- /dev/null +++ b/HeroHours/migrations/0021_alter_activitylog_operation_alter_activitylog_status.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-02-14 23:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('HeroHours', '0020_alter_users_last_in'), + ] + + operations = [ + migrations.AlterField( + model_name='activitylog', + name='operation', + field=models.CharField(choices=[('Check In', 'Check In'), ('Check Out', 'Check Out'), ('None', 'None'), ('Auto Check Out', 'Auto Check Out'), ('Reset', 'Reset')], max_length=15), + ), + migrations.AlterField( + model_name='activitylog', + name='status', + field=models.CharField(choices=[('Success', 'Success'), ('Error', 'Error'), ('User Not Found', 'User Not Found'), ('Inactive User', 'Inactive User')], max_length=20), + ), + ] \ No newline at end of file diff --git a/HeroHours/migrations/0022_alter_activitylog_message_alter_activitylog_user_and_more.py b/HeroHours/migrations/0022_alter_activitylog_message_alter_activitylog_user_and_more.py new file mode 100644 index 0000000..62256bc --- /dev/null +++ b/HeroHours/migrations/0022_alter_activitylog_message_alter_activitylog_user_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.11 on 2026-02-15 17:08 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('HeroHours', '0021_alter_activitylog_operation_alter_activitylog_status'), + ] + + operations = [ + migrations.AlterField( + model_name='activitylog', + name='message', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='activitylog', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity_logs', to='HeroHours.users'), + ), + migrations.AlterField( + model_name='users', + name='Last_In', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='users', + name='Last_Out', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='users', + name='Total_Seconds', + field=models.FloatField(default=0, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AddIndex( + model_name='activitylog', + index=models.Index(fields=['-timestamp'], name='HeroHours_a_timesta_b5495e_idx'), + ), + migrations.AddIndex( + model_name='activitylog', + index=models.Index(fields=['user', '-timestamp'], name='HeroHours_a_user_id_cb5e0f_idx'), + ), + migrations.AddIndex( + model_name='activitylog', + index=models.Index(fields=['operation', 'status'], name='HeroHours_a_operati_d5bf01_idx'), + ), + migrations.AddIndex( + model_name='users', + index=models.Index(fields=['Last_Name', 'First_Name'], name='Users_Last_Na_e25c91_idx'), + ), + migrations.AddIndex( + model_name='users', + index=models.Index(fields=['Checked_In'], name='Users_Checked_6fa301_idx'), + ), + migrations.AddIndex( + model_name='users', + index=models.Index(fields=['Is_Active'], name='Users_Is_Acti_bc9d4d_idx'), + ), + ] diff --git a/HeroHours/models.py b/HeroHours/models.py index 25984d5..27dfc86 100644 --- a/HeroHours/models.py +++ b/HeroHours/models.py @@ -1,21 +1,31 @@ from django.db import models +from django.core.validators import MinValueValidator # Create your models here. class Users(models.Model): + """ + Model representing a HERO member with check-in/check-out tracking. + + Tracks volunteer hours, check-in status, and activity for each member. + """ User_ID = models.IntegerField(primary_key=True) First_Name = models.CharField(max_length=50) Last_Name = models.CharField(max_length=50) Total_Hours = models.DurationField() Checked_In = models.BooleanField(default=False) - Total_Seconds = models.FloatField(default=0) - Last_In = models.DateTimeField(null=True) - Last_Out = models.DateTimeField(null=True) + Total_Seconds = models.FloatField(default=0, validators=[MinValueValidator(0)]) + Last_In = models.DateTimeField(null=True, blank=True) + Last_Out = models.DateTimeField(null=True, blank=True) Is_Active = models.BooleanField(default=True) - - def get_total_hours(self): - #print(f"Total Seconds: {self.Total_Seconds}") + def get_total_hours(self) -> str: + """ + Format total seconds as a human-readable hours/minutes/seconds string. + + Returns: + str: Formatted string like "10h 30m 45s" + """ hours, remainder = divmod(int(self.Total_Seconds), 3600) minutes, seconds = divmod(remainder, 60) return f"{hours}h {minutes}m {seconds}s" @@ -25,35 +35,51 @@ class Meta: db_table = 'Users' verbose_name = "Members" verbose_name_plural = "Members" + indexes = [ + models.Index(fields=['Last_Name', 'First_Name']), + models.Index(fields=['Checked_In']), + models.Index(fields=['Is_Active']), + ] def __str__(self): return f"{self.First_Name} {self.Last_Name}: {self.User_ID} - {self.Total_Hours}" class ActivityLog(models.Model): + """ + Model representing activity log entries for check-in/check-out operations. + + Tracks all user interactions including check-ins, check-outs, and errors. + """ OPERATION_CHOICES = [ - ('checkIn', 'Check In'), - ('checkOut', 'Check Out'), - ('none', "None"), - ('autoCheckOut','Auto Check Out'), + ('Check In', 'Check In'), + ('Check Out', 'Check Out'), + ('None', "None"), + ('Auto Check Out', 'Auto Check Out'), + ('Reset', 'Reset'), ] STATUS_CHOICES = [ - ('success', 'Success'), - ('error', 'Error'), - ('user not found', 'User Not Found'), - ('inactive user', 'Inactive User'), + ('Success', 'Success'), + ('Error', 'Error'), + ('User Not Found', 'User Not Found'), + ('Inactive User', 'Inactive User'), ] - user = models.ForeignKey(Users, models.CASCADE, blank=True, null=True) + user = models.ForeignKey(Users, models.CASCADE, blank=True, null=True, related_name='activity_logs') entered = models.TextField() - operation = models.CharField(max_length=14, choices=OPERATION_CHOICES) + operation = models.CharField(max_length=15, choices=OPERATION_CHOICES) status = models.CharField(max_length=20, choices=STATUS_CHOICES) - message = models.TextField(default='') # Optional message field + message = models.TextField(default='', blank=True) # Optional message field timestamp = models.DateTimeField(auto_now_add=True) # Automatically set the timestamp when creating def __str__(self): return f"{self.user_id} - {self.operation} - {self.status} - {self.timestamp}" class Meta: - ordering = ['-timestamp'] # Order by most recent logs first \ No newline at end of file + ordering = ['-timestamp'] # Order by most recent logs first + indexes = [ + models.Index(fields=['-timestamp']), + models.Index(fields=['user', '-timestamp']), + models.Index(fields=['operation', 'status']), + ] diff --git a/HeroHours/routing.py b/HeroHours/routing.py index cb86451..b17041a 100644 --- a/HeroHours/routing.py +++ b/HeroHours/routing.py @@ -4,4 +4,4 @@ websocket_urlpatterns = [ path('ws/live/',LiveConsumer.as_asgi()), -] +] \ No newline at end of file diff --git a/HeroHours/static/js/hours.js b/HeroHours/static/js/hours.js index d778ec1..31f259e 100644 --- a/HeroHours/static/js/hours.js +++ b/HeroHours/static/js/hours.js @@ -11,6 +11,7 @@ function playSound(status) { switch (status) { case "Success": + case "Check In": audio = successAudio; break; case "User Not Found": @@ -53,7 +54,7 @@ window.onload = function () { function updateTime() { const timeDiv = document.querySelector(".time"); const now = new Date(); - const prehours = now.getHours() == 12? 12 : now.getHours() % 12; + const prehours = now.getHours() === 0 ? 12 : (now.getHours() > 12 ? now.getHours() - 12 : now.getHours()); const hours = prehours.toString().padStart(2, "0"); const minutes = now.getMinutes().toString().padStart(2, "0"); const seconds = now.getSeconds().toString().padStart(2, "0"); @@ -85,7 +86,6 @@ document.addEventListener("DOMContentLoaded", function (event) { }); async function handleFormSubmission(event) { - console.time('benchmark'); let serialized_data = new URLSearchParams(new FormData(this)).toString(); let data = serialized_data.split("&")[1].split("=")[1]; if (data === "-404" || data === "%2B404" || data === "*"||data === 'admin'||data === '---') { @@ -100,24 +100,20 @@ async function handleFormSubmission(event) { return; } // submit the form ourselves - const req = await fetch(queryUrl, { - headers: { - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - 'X-Requested-With': 'XMLHttpRequest' - }, - body: serialized_data.toString().replaceAll("ID+",""), - method: "POST", - }).then(async function (response) { - console.log(response) - console.timeLog('benchmark'); + try { + const response = await fetch(queryUrl, { + headers: { + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + 'X-Requested-With': 'XMLHttpRequest' + }, + body: serialized_data.toString().replaceAll("ID+",""), + method: "POST", + }); //handle the form response ourselves document.getElementById("subID").reset(); // reset the input box if (response.ok) { - console.log(new Date()) - console.debug(response); // read the body let body = await response.json(); - console.debug(body); if (body.status == "Sent") return; // Play the sound based on the response playSound(body.status); @@ -135,23 +131,30 @@ async function handleFormSubmission(event) { console.error("Error:", response); addRow({ id: 0, - userID: data, + entered: data, operation: "None", status: response.statusText, message: "", }); } - }); - console.timeEnd('benchmark'); + } catch (error) { + console.error("Fetch error:", error); + document.getElementById("subID").reset(); // reset the input box on error + addRow({ + id: 0, + entered: data, + operation: "None", + status: "Error", + message: error.message || "", + }); + } } -async function addRow(item) { - console.log(item); +function addRow(item) { // Get the table body let table = document.getElementById("logBody"); let colorClass; let operationClass; - console.log(item); switch (item.status) { case "User Not Found": colorClass = "warning"; @@ -201,6 +204,6 @@ async function addRow(item) { cell3.innerHTML = `${item.status} ${item.message}`; } document.addEventListener('keydown', function(event) { - if( event.keyCode == 17 || event.keyCode == 74 ) + if (event.key === 'Control' || event.key === 'j') event.preventDefault(); - }); + }); \ No newline at end of file diff --git a/HeroHours/static/js/login.js b/HeroHours/static/js/login.js index e20de79..885fd8b 100644 --- a/HeroHours/static/js/login.js +++ b/HeroHours/static/js/login.js @@ -24,7 +24,7 @@ let username = document.getElementsByName("username")[0]; function updateTime() { const timeDiv = document.querySelector(".time"); const now = new Date(); - const prehours = now.getHours() % 12; + const prehours = now.getHours() === 0 ? 12 : (now.getHours() > 12 ? now.getHours() - 12 : now.getHours()); const hours = prehours.toString().padStart(2, "0"); const minutes = now.getMinutes().toString().padStart(2, "0"); const seconds = now.getSeconds().toString().padStart(2, "0"); @@ -65,6 +65,6 @@ let username = document.getElementsByName("username")[0]; username.focus(); }); document.addEventListener('keydown', function(event) { - if( event.keyCode == 17 || event.keyCode == 74 ) + if (event.key === 'Control' || event.key === 'j') event.preventDefault(); - }); + }); \ No newline at end of file diff --git a/HeroHours/tests.py b/HeroHours/tests.py index 7ce503c..c2629a3 100644 --- a/HeroHours/tests.py +++ b/HeroHours/tests.py @@ -1,3 +1,3 @@ from django.test import TestCase -# Create your tests here. +# Create your tests here. \ No newline at end of file diff --git a/HeroHours/urls.py b/HeroHours/urls.py index cf8c453..652e802 100644 --- a/HeroHours/urls.py +++ b/HeroHours/urls.py @@ -1,7 +1,7 @@ -import debug_toolbar +from django.conf import settings from django.contrib.auth.views import LoginView -from django.urls import path, include, re_path -from . import views, consumers +from django.urls import path, include +from . import views from .admin import add_user @@ -10,8 +10,13 @@ path("insert/", views.handle_entry, name='in-out'), path("send_data_to_google_sheet/",views.send_data_to_google_sheet,name='send_data_to_google_sheet'), path('login/', LoginView.as_view(template_name='login.html'), name='login'), - path('__debug__/', include(debug_toolbar.urls)), path('custom/', add_user,name='custom'), path('pull_sheet/',views.sheet_pull,name='pull_sheet'), path('live/', views.live_view, name='live_view'), -] \ No newline at end of file +] + +if settings.DEBUG: + import debug_toolbar + urlpatterns += [ + path('__debug__/', include(debug_toolbar.urls)), + ] \ No newline at end of file diff --git a/HeroHours/views.py b/HeroHours/views.py index 2ca5df8..868a163 100644 --- a/HeroHours/views.py +++ b/HeroHours/views.py @@ -1,61 +1,73 @@ -import base64 +# Standard library imports import json -import time -from datetime import timedelta - -import requests +import logging import os +from datetime import datetime, timedelta +from typing import Optional -from django.contrib.auth import authenticate, logout -from django.contrib.auth.decorators import login_required, permission_required -from django.core.exceptions import BadRequest, PermissionDenied -from django.db.models import F, DurationField, ExpressionWrapper -from django.shortcuts import render, redirect -from django.utils import timezone +# Third-party imports +import requests +from django.contrib.auth import logout +from django.contrib.auth.decorators import permission_required from django.core import serializers -from django.utils.http import urlsafe_base64_decode -from dotenv import load_dotenv, find_dotenv -from kombu.exceptions import HttpError +from django.db.models import DurationField, ExpressionWrapper, F +from django.forms.models import model_to_dict +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.utils import timezone +from django_ratelimit.decorators import ratelimit +from dotenv import find_dotenv, load_dotenv +# Local imports from . import models -from django.http import JsonResponse, HttpResponse -from django.forms.models import model_to_dict load_dotenv(find_dotenv()) +logger = logging.getLogger(__name__) + # Create your views here. @permission_required("HeroHours.change_users") -def index(request): +def index(request: HttpRequest) -> HttpResponse: + """ + Main dashboard view displaying active members and check-in status. + + Args: + request: HTTP request object + + Returns: + HttpResponse: Rendered members.html template with user data + """ # Query all users from the database - usersData = models.Users.objects.filter(Is_Active=True).order_by('Last_Name','First_Name') + users_data = models.Users.objects.filter(Is_Active=True).order_by('Last_Name', 'First_Name') users_checked_in = models.Users.objects.filter(Checked_In=True).count() - local_log_entries = models.ActivityLog.objects.all()[:9] #limits to loading only 9 entries - #print(local_log_entries) - print(timezone.now()) + local_log_entries = models.ActivityLog.objects.all()[:9] # Pass the users data to the template return render(request, 'members.html', - {'usersData': usersData, "checked_in": users_checked_in, 'local_log_entries': local_log_entries}) + {'usersData': users_data, "checked_in": users_checked_in, 'local_log_entries': local_log_entries}) @permission_required("HeroHours.change_users", raise_exception=True) -def handle_entry(request): - start_time = time.time() - user_input = request.POST.get('user_input') +@ratelimit(key='user', rate='60/m', method='POST') +def handle_entry(request: HttpRequest) -> JsonResponse: + user_input = request.POST.get('user_input', '').strip() + + # Input validation: limit length and sanitize + if not user_input: + return JsonResponse({'status': 'Error', 'message': 'No input provided'}) + + if len(user_input) > 100: + return JsonResponse({'status': 'Error', 'message': 'Input too long'}) + right_now = timezone.now() - #profiler = cProfile.Profile() - #profiler.enable() # Handle special commands first - if handle_special_commands(user_input): - elapsed_time = time.time() - start_time - print(f"input(before) execution time: {elapsed_time:.4f} seconds") - return handle_special_commands(user_input) + special_result = handle_special_commands(user_input) + if special_result: + return special_result if user_input in ['-404', '+404']: - elapsed_time = time.time() - start_time - print(f"input(before) execution time: {elapsed_time:.4f} seconds") return handle_bulk_updates(user_input) if user_input == "---": logout(request) @@ -77,43 +89,51 @@ def handle_entry(request): {'status': 'User Not Found', 'user_id': None, 'operation': None, 'newlog': model_to_dict(log), 'count': count}) except Exception as e: + logger.error(f"Error in handle_entry: {str(e)}") return JsonResponse({'status': "Error", 'newlog': {'userID': user_input, 'operation': "None", 'status': 'Error', - 'message': e.__str__()}, 'state': None, 'count': count}) + 'message': 'An error occurred'}, 'state': None, 'count': count}) # Perform Check-In or Check-Out operations - elapsed_time = time.time() - start_time - print(f"input(before) execution time: {elapsed_time:.4f} seconds") operation_result = check_in_or_out(user, right_now, log, count) - print(timezone.now()) - #profiler.disable() - #stats = pstats.Stats(profiler) - #stats.strip_dirs() - #stats.sort_stats('cumulative').print_stats(10) # Return JSON response with status and user info return JsonResponse(operation_result) -def handle_special_commands(user_id): - start_time = time.time() +def handle_special_commands(user_id: str) -> Optional[HttpResponse]: + """ + Process special command inputs like 'Send', 'admin', etc. + + Args: + user_id: Input string from user + + Returns: + HttpResponse or None: Redirect response if special command, None otherwise + """ if user_id == "Send": - elapsed_time = time.time() - start_time - print(f"input(send) execution time: {elapsed_time:.4f} seconds") return redirect('send_data_to_google_sheet') if user_id in ['+00', '+01', '*']: - elapsed_time = time.time() - start_time - print(f"input(special) execution time: {elapsed_time:.4f} seconds") return redirect('index') if user_id == "admin": - elapsed_time = time.time() - start_time - print(f"input(admin) execution time: {elapsed_time:.4f} seconds") return redirect('/admin/') - -def handle_bulk_updates(user_id, time = None): - if time == None: - time = timezone.now() + return None + + +def handle_bulk_updates(user_id: str, at_time: Optional[datetime] = None) -> HttpResponse: + """ + Bulk check-in or check-out all users (DEBUG mode only for check-in). + + Args: + user_id: '-404' for bulk check-in, '+404' for auto check-out + at_time: Optional datetime for the operation, defaults to now + + Returns: + HttpResponse: Redirect to index page + """ + if at_time is None: + at_time = timezone.now() updated_users = [] updated_log = [] @@ -130,36 +150,47 @@ def handle_bulk_updates(user_id, time = None): if user_id == '-404': user.Checked_In = True - user.Last_In = time + user.Last_In = at_time else: if not user.Last_In: - user.Last_In = time + user.Last_In = at_time user.Checked_In = False threshold = int(os.environ.get('AUTO_LOGOUT_THRESHOLD_SECONDS',3600)) - if (time - user.Last_In) > timedelta(seconds=threshold): - user.Total_Hours = ExpressionWrapper(F('Total_Hours') + ((time-timedelta(seconds=threshold)) - user.Last_In), + if (at_time - user.Last_In) > timedelta(seconds=threshold): + user.Total_Hours = ExpressionWrapper(F('Total_Hours') + ((at_time-timedelta(seconds=threshold)) - user.Last_In), output_field=DurationField()) - user.Total_Seconds = F('Total_Seconds') + round(((time-timedelta(seconds=threshold)) - user.Last_In).total_seconds()) + user.Total_Seconds = F('Total_Seconds') + round(((at_time-timedelta(seconds=threshold)) - user.Last_In).total_seconds()) else: - user.Total_Hours = ExpressionWrapper(F('Total_Hours') + (time - user.Last_In), + user.Total_Hours = ExpressionWrapper(F('Total_Hours') + (at_time - user.Last_In), output_field=DurationField()) - user.Total_Seconds = F('Total_Seconds') + round((time - user.Last_In).total_seconds()) - user.Last_Out = time + user.Total_Seconds = F('Total_Seconds') + round((at_time - user.Last_In).total_seconds()) + user.Last_Out = at_time updated_log.append(log) updated_users.append(user) - models.Users.objects.bulk_update(updated_users, ["Checked_In", "Total_Hours", "Total_Seconds", "Last_Out"]) + models.Users.objects.bulk_update(updated_users, ["Checked_In", "Total_Hours", "Total_Seconds", "Last_In", "Last_Out"]) models.ActivityLog.objects.bulk_create(updated_log) # Redirect to index after bulk updates return redirect('index') -def check_in_or_out(user, right_now, log, count): - start_time = time.time() - count2=count +def check_in_or_out(user: models.Users, right_now: datetime, log: models.ActivityLog, count: int) -> dict: + """ + Toggle user check-in status and update hours. + + Args: + user: Users model instance + right_now: Current datetime + log: ActivityLog instance to save + count: Current count of checked-in users + + Returns: + dict: Status information including operation, state, log, and count + """ + new_count = count if user.Checked_In: - count2 -= 1 + new_count -= 1 state = False log.operation = 'Check Out' if not user.Last_In: @@ -169,26 +200,24 @@ def check_in_or_out(user, right_now, log, count): user.Total_Seconds = F('Total_Seconds') + round((right_now - user.Last_In).total_seconds()) user.Last_Out = right_now else: - count2 += 1 + new_count += 1 state = True log.operation = 'Check In' user.Last_In = right_now user.Checked_In = not user.Checked_In log.status = 'Success' - operation = "Check Out" if not state else "Success" + operation = "Check Out" if not state else "Check In" if not user.Is_Active: log.operation = "None" state = None log.status = "Inactive User" else: - count = count2 + count = new_count user.save() # Save log and user updates log.save() - elapsed_time = time.time() - start_time - print(f"input(in or out) execution time: {elapsed_time:.4f} seconds") return { 'status': operation, 'state': state, @@ -197,15 +226,24 @@ def check_in_or_out(user, right_now, log, count): } -APP_SCRIPT_URL = os.environ['APP_SCRIPT_URL'] +APP_SCRIPT_URL = os.environ.get('APP_SCRIPT_URL', '') @permission_required("HeroHours.change_users", raise_exception=True) -def send_data_to_google_sheet(request): +@ratelimit(key='user', rate='10/m', method='POST') +def send_data_to_google_sheet(request: HttpRequest) -> JsonResponse: + """ + Export all users and activity logs to Google Sheets via Apps Script. + + Args: + request: HTTP request object + + Returns: + JsonResponse: Status of the export operation + """ users = models.Users.objects.all() serialized_data = serializers.serialize('json', users, use_natural_foreign_keys=True) serialized_data2 = serializers.serialize('json', models.ActivityLog.objects.all(), use_natural_foreign_keys=True) - print(serialized_data) together = [serialized_data, serialized_data2] all_data = json.dumps(obj=together) count = users.filter(Checked_In=True).count() @@ -213,40 +251,36 @@ def send_data_to_google_sheet(request): # Send POST request to the Apps Script API try: response = requests.post(APP_SCRIPT_URL, json=json.loads(all_data)) - print(response) # Handle the response (for example, check if it was successful) if response.status_code == 200: result = response.json() - print(result) return JsonResponse({'status': 'Sent', 'result': result, 'count': count}) else: return JsonResponse({'status': 'Sent', 'message': 'Failed to send data', 'count': count}) except Exception as e: - print("failed") - print(e) + logger.error("Failed to send data to Google Sheet: %s", e) return JsonResponse({'status': 'error', 'message': str(e), 'count': count}) -def sheet_pull(request): - key = request.GET.get('key') - if not key: - raise BadRequest() - - username, password = base64.b64decode(key).decode('ascii').split(":") - user = authenticate(request, username=username, password=password) - if not user: - print(user) - raise PermissionDenied() + + +@permission_required("HeroHours.view_users", raise_exception=True) +@ratelimit(key='user', rate='30/m', method='GET') +def sheet_pull(request: HttpRequest) -> HttpResponse: + """ + Export users data to CSV format. + This view is deprecated. Use the API endpoint /api/sheet-pull/ with token authentication instead. + """ members = models.Users.objects.all() response = 'User_ID,First_Name,Last_Name,Total_Hours,Total_Seconds,Last_In,Last_Out,Is_Active,\n' for member in members: - response += f"{member.User_ID},{member.First_Name},{member.Last_Name},{member.get_p()},{member.Total_Seconds},{member.Last_In},{member.Last_Out},{member.Is_Active}\n" - return HttpResponse(response,content_type='text/csv') + response += f"{member.User_ID},{member.First_Name},{member.Last_Name},{member.get_total_hours()},{member.Total_Seconds},{member.Last_In},{member.Last_Out},{member.Is_Active}\n" + return HttpResponse(response, content_type='text/csv') -def logout_view(request): +def logout_view(request: HttpRequest) -> HttpResponse: logout(request) return redirect('login') @permission_required("HeroHours.change_users") -def live_view(request): - return render(request, 'live.html') \ No newline at end of file +def live_view(request: HttpRequest) -> HttpResponse: + return render(request, 'live.html') diff --git a/HeroHoursRemake/celery.py b/HeroHoursRemake/celery.py index c97a1a9..b2e4e4e 100644 --- a/HeroHoursRemake/celery.py +++ b/HeroHoursRemake/celery.py @@ -14,4 +14,4 @@ @app.task(bind=True) def debug_task(self): - print(f'Request: {self.request!r}') + print(f'Request: {self.request!r}') \ No newline at end of file diff --git a/HeroHoursRemake/settings.py b/HeroHoursRemake/settings.py index 9e3393e..c1ec299 100644 --- a/HeroHoursRemake/settings.py +++ b/HeroHoursRemake/settings.py @@ -10,11 +10,11 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ """ -from pathlib import Path import os +from pathlib import Path import dj_database_url -from dotenv import load_dotenv, find_dotenv +from dotenv import find_dotenv, load_dotenv load_dotenv(find_dotenv()) @@ -46,7 +46,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'HeroHours.apps.HeroHoursConfig', - 'debug_toolbar', + 'HeroHours_api.apps.HerohoursApiConfig', 'rest_framework', 'rest_framework.authtoken', ] @@ -54,23 +54,25 @@ LOGIN_URL = '/HeroHours/login/' MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - #'HeroHours.middleware.TimeItMiddleware', - 'debug_toolbar.middleware.DebugToolbarMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', ] + +if DEBUG: + INSTALLED_APPS += ['debug_toolbar'] + MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] INTERNAL_IPS = ['127.0.0.1'] ROOT_URLCONF = 'HeroHoursRemake.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -89,7 +91,10 @@ CHANNEL_LAYERS = { "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", + "BACKEND": "channels_redis.core.RedisChannelLayer" if not DEBUG else "channels.layers.InMemoryChannelLayer", + "CONFIG": { + "hosts": [os.environ.get('REDIS_URL', 'redis://localhost:6379')], + } if not DEBUG else {}, } } @@ -101,7 +106,7 @@ 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', }, - 'default': dj_database_url.config(default=os.environ['DATABASE_URL'], conn_max_age=600, ssl_require=not DEBUG) + 'default': dj_database_url.config(default=os.environ.get('DATABASE_URL', ''), conn_max_age=600, ssl_require=not DEBUG) } default_database = os.environ.get('DJANGO_DATABASE', 'default') DATABASES['default'] = DATABASES[default_database] @@ -143,11 +148,15 @@ # https://docs.djangoproject.com/en/5.1/howto/static-files/ STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} MEDIA_URL = '/media/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_ROOT = BASE_DIR / 'media' # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field @@ -156,4 +165,81 @@ CSRF_TRUSTED_ORIGINS = ['https://hero-hours-2bf608a75758.herokuapp.com'] SECURE_SSL_REDIRECT = not DEBUG APPEND_SLASH = True -SESSION_COOKIE_AGE=39600 +SESSION_COOKIE_AGE = 39600 + +# Security settings for production +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_BROWSER_XSS_FILTER = True +X_FRAME_OPTIONS = 'DENY' +SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG +SECURE_HSTS_PRELOAD = not DEBUG + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '30/hour', + 'user': '100/hour', + }, +} + +# Logging configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'file': { + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': BASE_DIR / 'logs' / 'django.log', + 'maxBytes': 1024 * 1024 * 5, # 5 MB + 'backupCount': 5, + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console', 'file'] if not DEBUG else ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'HeroHours': { + 'handlers': ['console', 'file'] if not DEBUG else ['console'], + 'level': 'DEBUG' if DEBUG else 'INFO', + 'propagate': False, + }, + 'HeroHours_api': { + 'handlers': ['console', 'file'] if not DEBUG else ['console'], + 'level': 'DEBUG' if DEBUG else 'INFO', + 'propagate': False, + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} diff --git a/HeroHoursRemake/urls.py b/HeroHoursRemake/urls.py index e453bd0..6e3c210 100644 --- a/HeroHoursRemake/urls.py +++ b/HeroHoursRemake/urls.py @@ -33,4 +33,4 @@ def root_redirect(request): path('admin/', admin.site.urls), path('test/', home), path('', root_redirect), -] +] \ No newline at end of file diff --git a/HeroHoursRemake/wsgi.py b/HeroHoursRemake/wsgi.py index c160810..ec0203f 100644 --- a/HeroHoursRemake/wsgi.py +++ b/HeroHoursRemake/wsgi.py @@ -13,4 +13,4 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'HeroHoursRemake.settings') -application = get_wsgi_application() +application = get_wsgi_application() \ No newline at end of file diff --git a/HeroHours_api/apps.py b/HeroHours_api/apps.py index a8d6a63..8b6e65f 100644 --- a/HeroHours_api/apps.py +++ b/HeroHours_api/apps.py @@ -3,4 +3,4 @@ class HerohoursApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'HeroHours_api' + name = 'HeroHours_api' \ No newline at end of file diff --git a/HeroHours_api/authentication.py b/HeroHours_api/authentication.py index 7630e2e..f66232f 100644 --- a/HeroHours_api/authentication.py +++ b/HeroHours_api/authentication.py @@ -98,4 +98,4 @@ def get_authorization_key(request): if isinstance(auth, str): # Work around django test client oddness auth = auth.encode(HTTP_HEADER_ENCODING) - return auth + return auth \ No newline at end of file diff --git a/HeroHours_api/urls.py b/HeroHours_api/urls.py index 598605c..ebe0748 100644 --- a/HeroHours_api/urls.py +++ b/HeroHours_api/urls.py @@ -1,11 +1,7 @@ -from django.urls import path, re_path +from django.urls import path +from .views import SheetPullAPI, MeetingPullAPI -from HeroHours_api import views -from .views import ( - SheetPullAPI, - MeetingPullAPI -) urlpatterns = [ path('sheet/users/', SheetPullAPI.as_view(), name='sheet'), - path('sheet////', MeetingPullAPI.as_view(),name='sheet meeting') + path('sheet////', MeetingPullAPI.as_view(), name='sheet meeting'), ] \ No newline at end of file diff --git a/HeroHours_api/views.py b/HeroHours_api/views.py index d35fe51..9959e76 100644 --- a/HeroHours_api/views.py +++ b/HeroHours_api/views.py @@ -1,24 +1,29 @@ -from django.http import HttpResponse +# Third-party imports +from django.db.models import Subquery from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - +from rest_framework.throttling import UserRateThrottle from rest_framework.views import APIView -from rest_framework.settings import api_settings from rest_framework_csv import renderers as csv_renderers -from django.db.models import Subquery -from HeroHours.models import Users, ActivityLog -from HeroHoursRemake import settings + +# Local imports +from HeroHours.models import ActivityLog, Users from HeroHours_api.authentication import URLTokenAuthentication # Create your views here. +class SheetPullThrottle(UserRateThrottle): + rate = '30/hour' + class SheetPullRenderer(csv_renderers.CSVRenderer): header = ['Id','Last Name','First Name','Is Active','Hours','Checked In','Last In','Last Out'] + class SheetPullAPI(APIView): renderer_classes = [SheetPullRenderer] authentication_classes = [URLTokenAuthentication] permission_classes = [IsAuthenticated] + throttle_classes = [SheetPullThrottle] def get(self, request, *args, **kwargs): members = Users.objects.all().order_by('Last_Name','First_Name') @@ -34,6 +39,9 @@ def get(self, request, *args, **kwargs): } for member in members] return Response(content, status=status.HTTP_200_OK) +class MeetingListThrottle(UserRateThrottle): + rate = '30/hour' + class MeetingListRender(csv_renderers.CSVRenderer): header = ['user_id','user__Last_Name','user__First_Name'] labels = { @@ -41,17 +49,29 @@ class MeetingListRender(csv_renderers.CSVRenderer): 'user__Last_Name': 'Last Name', 'user__First_Name': 'First Name', } + class MeetingPullAPI(APIView): renderer_classes = [MeetingListRender] authentication_classes = [URLTokenAuthentication] permission_classes = [IsAuthenticated] + throttle_classes = [MeetingListThrottle] def get(self, request, day, month, year, *args, **kwargs): + # Validate input parameters + try: + day = int(day) + month = int(month) + year = int(year) + if not (1 <= day <= 31 and 1 <= month <= 12 and 1900 <= year <= 2100): + return Response({'error': 'Invalid date parameters'}, status=status.HTTP_400_BAD_REQUEST) + except (ValueError, TypeError): + return Response({'error': 'Invalid date parameters'}, status=status.HTTP_400_BAD_REQUEST) + query = ActivityLog.objects.filter(id__in=Subquery( ActivityLog.objects.all() - .filter(timestamp__day=str(day),timestamp__month=str(month),timestamp__year=str(year),operation='Check In') \ - .order_by('user_id').distinct('user_id').values('id') - )).order_by('user__Last_Name','user__First_Name').values('user_id','user__First_Name','user__Last_Name') + .filter(timestamp__day=day, timestamp__month=month, timestamp__year=year, operation='Check In') + .order_by('user_id').distinct('user_id').values('id') + )).order_by('user__Last_Name', 'user__First_Name').values('user_id', 'user__First_Name', 'user__Last_Name') members = list(query) - return Response(members, status=status.HTTP_200_OK) \ No newline at end of file + return Response(members, status=status.HTTP_200_OK) diff --git a/Procfile b/Procfile index c9a9b82..ed44585 100644 --- a/Procfile +++ b/Procfile @@ -1,4 +1 @@ -web: daphne HeroHoursRemake.asgi:application --port $PORT --bind 0.0.0.0 --proxy-headers -heroku run python manage.py collectstatic -heroku run python manage.py makemigrations -heroku run python manage.py migrate +web: daphne HeroHoursRemake.asgi:application --port $PORT --bind 0.0.0.0 --proxy-headers \ No newline at end of file diff --git a/flake.lock b/flake.lock index 28302f3..6b709d0 100644 --- a/flake.lock +++ b/flake.lock @@ -57,4 +57,4 @@ }, "root": "root", "version": 7 -} +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index 3e01fd6..7329433 100644 --- a/flake.nix +++ b/flake.nix @@ -54,4 +54,4 @@ ''; }; }); -} +} \ No newline at end of file diff --git a/manage.py b/manage.py index 5d0a384..b1f6603 100644 --- a/manage.py +++ b/manage.py @@ -7,6 +7,9 @@ def main(): """Run administrative tasks.""" os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'HeroHoursRemake.settings') + if len(sys.argv) >= 2 and sys.argv[1] == 'runserver': + if not any(arg.startswith(('127.0.0.1:', 'localhost:', '0.0.0.0:')) for arg in sys.argv[2:]): + sys.argv.append('127.0.0.1:5892') try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -19,4 +22,4 @@ def main(): if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1763583..d1740f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,41 +4,32 @@ async-timeout==4.0.3 attrs==25.4.0 autobahn==25.12.2 Automat==25.4.16 -automium==0.2.6 -automium_web==0.1.1 billiard==4.2.1 -blinker==1.8.2 cbor2==5.8.0 celery==5.4.0 certifi==2024.8.30 cffi==2.0.0 channels==4.3.2 +channels-redis==4.2.1 charset-normalizer==3.3.2 click==8.1.7 click-didyoumean==0.3.1 click-plugins==1.1.1 click-repl==0.3.0 -colorama==0.4.6 constantly==23.10.4 -cryptography==46.0.3 +cryptography==46.0.5 daphne==4.2.1 dj-database-url==2.2.0 -Django==5.2.9 +Django==5.2.11 django-debug-toolbar==4.4.6 -django-sslserver==0.22 +django-ratelimit==4.1.0 djangochannelsrestframework==1.3.0 djangorestframework==3.15.2 djangorestframework-csv==3.0.2 -Flask==3.0.3 -gunicorn==23.0.0 hyperlink==21.0.0 idna==3.10 Incremental==24.11.0 -itsdangerous==2.2.0 -Jinja2==3.1.4 kombu==5.4.2 -legacy==0.1.7 -MarkupSafe==2.1.5 msgpack==1.1.2 packaging==24.1 prompt_toolkit==3.0.48 @@ -62,9 +53,8 @@ txaio==25.12.2 typing_extensions==4.12.2 tzdata==2024.2 ujson==5.11.0 -urllib3==2.2.3 +urllib3==2.6.3 vine==5.1.0 wcwidth==0.2.13 -Werkzeug==3.0.4 whitenoise==6.7.0 zope.interface==8.1.1 diff --git a/templates/admin/custom_action_form.html b/templates/admin/custom_action_form.html index 8f56be0..3779ef0 100644 --- a/templates/admin/custom_action_form.html +++ b/templates/admin/custom_action_form.html @@ -18,4 +18,4 @@

Enter Information for Creating a Staff User

}) {{ admin_tools }} -{% endblock %} +{% endblock %} \ No newline at end of file