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