diff --git a/Pipfile b/Pipfile index 26c3d3c..b2b100d 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,7 @@ django = "*" django-compressor = "*" qrcode = "*" pillow = "*" +django-qr-code = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 7d124a4..5f3847f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "35627f97351787e78b306a9071b0db88411b941484359b8622cd032f3f6a64f2" + "sha256": "d1dd018056bda89f00de6e3fbdd88cfa2fc56b756047e88153a4f8e99171e1bb" }, "pipfile-spec": 6, "requires": { @@ -47,6 +47,14 @@ "index": "pypi", "version": "==2.4" }, + "django-qr-code": { + "hashes": [ + "sha256:5fdd84f4c02127c262e6987bd1a9396a47f2b806be04aa0bebfcb786a9f6a0b7", + "sha256:ff495d9628e3d02ad8581c3b3f17940008d6613c69ffdc5abb67e02f8159db6e" + ], + "index": "pypi", + "version": "==2.1.0" + }, "pillow": { "hashes": [ "sha256:165c88bc9d8dba670110c689e3cc5c71dbe4bfb984ffa7cbebf1fac9554071d6", @@ -124,6 +132,14 @@ ], "version": "==1.1.0" }, + "segno": { + "hashes": [ + "sha256:309281263ba820e49ce44556a27779709b86769b8f2161f94641a3119684dc4e", + "sha256:51634ea16d2104e0a8f9309beb785abfdbc1b8c0fffef5d74c80522eec7490d6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.3.1" + }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", diff --git a/accounts/models.py b/accounts/models.py index d06bcf1..2e2f3f2 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -8,6 +8,9 @@ class Department(models.Model): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) + def __str__(self): + return self.name + class Instructor(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) department = models.ForeignKey(Department, on_delete=models.CASCADE) diff --git a/accounts/templates/accounts/account_form.html b/accounts/templates/accounts/account_form.html index 3706fc9..083ffd0 100644 --- a/accounts/templates/accounts/account_form.html +++ b/accounts/templates/accounts/account_form.html @@ -14,6 +14,6 @@ -

Update password

+

Change password

{% endblock %} diff --git a/attendance/forms.py b/attendance/forms.py index 0afb906..0fd6005 100644 --- a/attendance/forms.py +++ b/attendance/forms.py @@ -1,2 +1,9 @@ from django import forms +from .models import Period +class AttendanceUpdateForm(forms.Form): + qr_string = forms.CharField( + label='Scan QR code', + max_length=100, + widget=forms.TextInput(attrs={'autofocus': True}) + ) diff --git a/attendance/models.py b/attendance/models.py index d49997c..d17d700 100644 --- a/attendance/models.py +++ b/attendance/models.py @@ -1,23 +1,40 @@ from django.db import models +from django.urls import reverse from accounts.models import Student class Code(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE) - qr_code = models.ImageField(upload_to='qr_codes', blank=True) + station_number = models.IntegerField() + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + def qr_code_str(self): + return f'{self.student.student_number}:{self.station_number}' + + def get_absolute_url(self): + return reverse('code-detail', kwargs={'pk': self.pk}) def __str__(self): - return str(self.name) + return f'{self.student.student_number}:{self.station_number}' class Period(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE) clocked_in = models.DateTimeField(auto_now_add=True) clocked_out = models.DateTimeField(blank=True, null=True) + station_number = models.IntegerField() + duration = models.DurationField(blank=True, null=True) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) - def duration(self): - return round(self.clocked_out - self.clocked_in) + # def duration(self): + # duration = self.clocked_out - self.clocked_in + # duration_in_s = duration.total_seconds() + # return divmod(duration_in_s, 3600)[0] + + def get_absolute_url(self): + return reverse('period-detail', kwargs={'pk': self.pk}) def __str__(self): return f'{self.clocked_in}: {self.student.user.first_name} {self.student.user.last_name}' diff --git a/attendance/templates/attendance/attendance_form.html b/attendance/templates/attendance/attendance_form.html new file mode 100644 index 0000000..d3c38f9 --- /dev/null +++ b/attendance/templates/attendance/attendance_form.html @@ -0,0 +1,14 @@ +{% extends 'application.html' %} + +{% block content %} +

Scan code to clock-in/out

+ +
+
+ {% csrf_token %} + {{ form.as_p }} + + +
+
+{% endblock %} diff --git a/attendance/templates/attendance/attendance_overview_instructor.html b/attendance/templates/attendance/attendance_overview_instructor.html index 42e96e5..de5d202 100644 --- a/attendance/templates/attendance/attendance_overview_instructor.html +++ b/attendance/templates/attendance/attendance_overview_instructor.html @@ -1,29 +1,76 @@
-

{{ user.instructor.department.name }}

- Update profile +

{{ user.instructor.department.name }}

+

+ Generate Reports +

-

{{ user.first_name }} {{ user.last_name }}

+ +

Active sessions

+ + + + + + + + + + + {% for period in period_list %} + {% if not period.clocked_out %} + + + + + {% if period.clocked_out %} + + + {% else %} + + {% endif %} + + + {% endif %} + {% empty %} + + {% endfor %} + +
StudentStationClocked inDuration
{{ period.student }}{{ period.station_number }}{{ period.clocked_in }}{{ period.clocked_out }}{{ period.clocked_in|timesince:period.clocked_out }}Current sesson: {{ period.clocked_in|timesince }}View
No periods yet.
-

Attendance log

- - - {% for student in students %} +
+

Attendance log

+
+ - - {% for period in student.period_set.all %} - - {% if period.clocked_out %} - - {% else %} - - {% endif %} - {% endfor %} + + + + + - {% empty %} - - {% endfor %} - -
{{ student.user.first_name }}{{ period.clocked_in }}{{ period.clocked_out }}You have not clocked out.StudentStationClocked inClocked outDuration
No periods yet.
+ + + {% for period in period_list %} + {% if period.clocked_out %} + + {{ period.student }} + {{ period.station_number }} + {{ period.clocked_in }} + {% if period.clocked_out %} + {{ period.clocked_out }} + {{ period.clocked_in|timesince:period.clocked_out }} + {% else %} + Current sesson: {{ period.clocked_in|timesince }} + {% endif %} + View + + {% endif %} + {% empty %} + No periods yet. + {% endfor %} + + +
diff --git a/attendance/templates/attendance/attendance_overview_student.html b/attendance/templates/attendance/attendance_overview_student.html index 46e5eaf..7033e47 100644 --- a/attendance/templates/attendance/attendance_overview_student.html +++ b/attendance/templates/attendance/attendance_overview_student.html @@ -1,30 +1,48 @@
-

{{ user.student.department.name }}

+

{{ user.first_name }} {{ user.last_name }}

Update profile
-

{{ user.first_name }} {{ user.last_name }}

+
{{ user.student.department.name }}
{% if user.student.is_clocked_in %} -

You are currently clocked in.

- Clock out > +

You are currently clocked in.
+ Current sesson: {{ period_list.first.clocked_in|timesince }}

+ Clock out → + {% elif user.student.code_set.last %} +

You are not clocked in.

+ Clock in → {% else %}

You are not clocked in.

- Clock in > + Clock in → {% endif %}

Attendance log

+

Total hours for the month: {{period_total}}
+ (Does not include current session.)

+ + + + + + + + - {% for period in user.student.period_set.all %} - - - {% if period.clocked_out %} - - {% else %} - - {% endif %} - + {% for period in period_list %} + {% if period.clocked_out %} + + + + {% if period.clocked_out %} + + + {% else %} + + {% endif %} + + {% endif %} {% empty %} {% endfor %} diff --git a/attendance/templates/attendance/code_detail.html b/attendance/templates/attendance/code_detail.html new file mode 100644 index 0000000..56be2e8 --- /dev/null +++ b/attendance/templates/attendance/code_detail.html @@ -0,0 +1,14 @@ +{% extends 'application.html' %} +{% load qr_code %} + +{% block content %} +

Clock in

+ +
+

+ {% qr_from_text code.qr_code_str size=24 version=2 %} +

+

Scan code at clock-in station

+ Done +
+{% endblock %} diff --git a/attendance/templates/attendance/code_form.html b/attendance/templates/attendance/code_form.html new file mode 100644 index 0000000..f6fb5dd --- /dev/null +++ b/attendance/templates/attendance/code_form.html @@ -0,0 +1,14 @@ +{% extends 'application.html' %} + +{% block content %} +

Generate code

+ +
+
+ {% csrf_token %} + {{ form.as_p }} + + + +
+{% endblock %} diff --git a/attendance/templates/attendance/code_update_form.html b/attendance/templates/attendance/code_update_form.html new file mode 100644 index 0000000..ff4806f --- /dev/null +++ b/attendance/templates/attendance/code_update_form.html @@ -0,0 +1,14 @@ +{% extends 'application.html' %} + +{% block content %} +

Generate code

+ +
+
+ {% csrf_token %} + {{ form.as_p }} + + + +
+{% endblock %} diff --git a/attendance/templates/attendance/period_confirm_delete.html b/attendance/templates/attendance/period_confirm_delete.html new file mode 100644 index 0000000..54ca616 --- /dev/null +++ b/attendance/templates/attendance/period_confirm_delete.html @@ -0,0 +1,10 @@ +{% extends 'application.html' %} +{% load static %} + +{% block content %} + + {% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ + +{% endblock %} diff --git a/attendance/templates/attendance/period_detail.html b/attendance/templates/attendance/period_detail.html new file mode 100644 index 0000000..5d12cbb --- /dev/null +++ b/attendance/templates/attendance/period_detail.html @@ -0,0 +1,24 @@ +{% extends 'application.html' %} + +{% block content %} +

Period

+ +
+
+ ← Back +
+ Edit + Delete + +
+
+
+
Student
+
{{ period.student }}
+
Clocked in
+
{{ period.clocked_in }}
+
Clocked out
+
{{ period.clocked_out }}
+
+
+{% endblock %} diff --git a/attendance/templates/attendance/period_form.html b/attendance/templates/attendance/period_form.html new file mode 100644 index 0000000..4cc43c4 --- /dev/null +++ b/attendance/templates/attendance/period_form.html @@ -0,0 +1,14 @@ +{% extends 'application.html' %} + +{% block content %} +

Generate Period

+ +
+
+ {% csrf_token %} + {{ form.as_p }} + + + +
+{% endblock %} diff --git a/attendance/urls.py b/attendance/urls.py index 316ce1d..3fd30f4 100644 --- a/attendance/urls.py +++ b/attendance/urls.py @@ -9,11 +9,15 @@ from . import views urlpatterns = [ path('', views.AttendanceOverview.as_view(), name='attendance-overview'), - path('new/', views.AttendanceCreateView.as_view(), name='attendance-create'), - path('/', include([ - path('', views.AttendanceDetailView.as_view(), name='attendance-detail'), - path('update/', views.AttendanceUpdateView.as_view(), name='attendance-update'), - path('delete/', views.AttendanceDeleteView.as_view(), name='attendance-delete'), + path('update/', views.AttendanceUpdateView.as_view(), name='attendance-update'), + path('periods//', include([ + path('', views.PeriodDetailView.as_view(), name='period-detail'), + path('update/', views.PeriodUpdateView.as_view(), name='period-update'), + path('delete/', views.PeriodDeleteView.as_view(), name='period-delete'), ])), +] + [ + path('codes/new/', views.CodeCreateView.as_view(), name='code-create'), + path('codes//', views.CodeDetailView.as_view(), name='code-detail'), + path('codes//update/', views.CodeUpdateView.as_view(), name='code-update'), ] diff --git a/attendance/views.py b/attendance/views.py index 3a73765..22a31da 100644 --- a/attendance/views.py +++ b/attendance/views.py @@ -1,4 +1,5 @@ from django.shortcuts import render, reverse +from django.db.models import Avg, Count, Min, Sum from django.urls import reverse_lazy from django.views.generic.base import TemplateView from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView @@ -6,9 +7,12 @@ from django.views.generic.detail import DetailView from django.views.generic.list import ListView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.utils import timezone +from django.contrib import messages from django.contrib.auth.models import User from accounts.models import Instructor, Student -from .models import Period +from .models import Code, Period +from .forms import AttendanceUpdateForm # EXAMPLE PERMISSION MIXIN # class MyView(PermissionRequiredMixin, View): @@ -16,6 +20,7 @@ from .models import Period # # Or multiple of permissions: # permission_required = ('polls.view_choice', 'polls.change_choice') +# OVERVIEW class AttendanceOverview(LoginRequiredMixin, TemplateView): template_name = 'attendance/attendance_overview.html' @@ -23,28 +28,106 @@ class AttendanceOverview(LoginRequiredMixin, TemplateView): context = super().get_context_data(**kwargs) context['user'] = User.objects.get(pk=self.request.user.id) if hasattr(self.request.user, 'instructor'): - context['students'] = Student.objects.filter(department=self.request.user.instructor.department) - context['period_list'] = Period.objects.all() + context['student_list'] = Student.objects.filter(department=self.request.user.instructor.department) + context['period_list'] = Period.objects.order_by('-clocked_in') + elif hasattr(self.request.user, 'student'): + # sum all duration fields for student + total_duration = Period.objects.filter( + student=self.request.user.student + ).filter( + clocked_in__year=timezone.now().year + ).filter( + clocked_in__month=timezone.now().month + ).order_by('-clocked_in' + ).aggregate(total_duration=Sum('duration')) + + # Convert to hours floating point + hours = round((total_duration['total_duration'].total_seconds() / 3600), 2) + + context['period_list'] = Period.objects.filter( + student=self.request.user.student + ).filter( + clocked_in__year=timezone.now().year + ).filter( + clocked_in__month=timezone.now().month + ).order_by('-clocked_in') + context['period_total'] = hours return context -class AttendanceCreateView(LoginRequiredMixin, CreateView): - model = Period - fields = ['email', 'password'] +class AttendanceUpdateView(LoginRequiredMixin, FormView): template_name = 'attendance/attendance_form.html' + form_class = AttendanceUpdateForm -class AttendanceDetailView(LoginRequiredMixin, DetailView): - model = Period - template_name = 'attendance/attendance_detail.html' + def form_valid(self, form): + # update checked in + student_number = form.cleaned_data['qr_string'].split(':')[0] + station_number = form.cleaned_data['qr_string'].split(':')[1] + student = Student.objects.get(student_number=student_number) + if student.is_clocked_in: + student.is_clocked_in=False + period = student.period_set.last() + period.clocked_out=timezone.now() + period.duration=period.clocked_out-period.clocked_in + student.save() + period.save() + messages.add_message(self.request, messages.INFO, f'{student.user.first_name} {student.user.last_name} clocked out.') + else: + student.period_set.create(student=student, station_number=station_number) + student.is_clocked_in=True + student.save() + messages.add_message(self.request, messages.INFO, f'{student.user.first_name} {student.user.last_name} clocked in.') + return super().form_valid(form) -class AttendanceUpdateView(LoginRequiredMixin, UpdateView): + def get_success_url(self): + return reverse('attendance-update') + + + + +# PERIODS +class PeriodCreateView(LoginRequiredMixin, CreateView): model = Period - fields = ['username', 'email'] - template_name = 'attendance/attendance_form.html' + fields = ['student'] + template_name = 'attendance/period_form.html' + +class PeriodDetailView(LoginRequiredMixin, DetailView): + model = Period + template_name = 'attendance/period_detail.html' + +class PeriodUpdateView(LoginRequiredMixin, UpdateView): + model = Period + fields = ['clocked_out'] + template_name = 'attendance/period_form.html' def get_success_url(self): pk = self.kwargs["pk"] - return reverse('attendance-detail', kwargs={'pk': pk}) + return reverse('period-detail', kwargs={'pk': pk}) -class AttendanceDeleteView(LoginRequiredMixin, DeleteView): +class PeriodDeleteView(LoginRequiredMixin, DeleteView): model = Period - success_url = reverse_lazy('account-overview') + success_url = '/attendance' + + + +# CODES +class CodeCreateView(LoginRequiredMixin, CreateView): + model = Code + fields = ['station_number'] + template_name = 'attendance/code_form.html' + + def form_valid(self, form): + form.instance.student = self.request.user.student + return super().form_valid(form) + +class CodeDetailView(LoginRequiredMixin, DetailView): + model = Code + template_name = 'attendance/code_detail.html' + +class CodeUpdateView(LoginRequiredMixin, UpdateView): + model = Code + fields = ['station_number'] + template_name = 'attendance/code_update_form.html' + + def get_success_url(self): + pk = self.kwargs["pk"] + return reverse('code-detail', kwargs={'pk': pk}) diff --git a/static/css/base.css b/static/css/base.css index e453550..5ce7dd8 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -13,9 +13,9 @@ html { } body { - max-width: 8.5in; + max-width: 1024px; margin: 0.25in auto; - background-color: white; + background-color: #f7f7f7; color: #323834; font-family: 'Heebo', sans-serif; font-weight: 400; @@ -183,8 +183,8 @@ textarea { section { padding: 1em; + background-color: white; border: 1px solid #bdc3c7; - box-shadow: 0 0 4px #bdc3c7; margin-bottom: 1.3em; -webkit-box-sizing: border-box; @@ -213,3 +213,10 @@ th { border-bottom: 1px solid; border-color: #bdc3c7; } + + + + + +/* Messages */ +.messages > .info {color: green;} diff --git a/templates/application.html b/templates/application.html index c845155..6edf910 100644 --- a/templates/application.html +++ b/templates/application.html @@ -2,7 +2,7 @@ - Tracker + BTech Tracker @@ -13,9 +13,10 @@
-

Tracker

+

BTech Time Tracker

{% if user.is_authenticated %} {% else %} @@ -33,6 +34,13 @@
{% block content %} {% endblock %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %}
diff --git a/tracker/settings.py b/tracker/settings.py index c038c9f..64c6e4c 100644 --- a/tracker/settings.py +++ b/tracker/settings.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'compressor', + 'qr_code', 'accounts.apps.AccountsConfig', 'attendance.apps.AttendanceConfig', ] @@ -134,7 +135,7 @@ STATIC_ROOT = '/var/www/static/' # Media file storage MEDIA_URL = '/media/' -MEDIA_ROOT = [BASE_DIR / 'media'] +MEDIA_ROOT = BASE_DIR / 'media' LOGIN_REDIRECT_URL = '/attendance'
StationClocked inClocked outDuration
{{ period.clocked_in }}{{ period.clocked_out }}You have not clocked out.
{{ period.station_number }}{{ period.clocked_in }}{{ period.clocked_out }}{{ period.clocked_in|timesince:period.clocked_out }}Current sesson: {{ period.clocked_in|timesince }}
No periods yet.