Minimum viable product, acheived

This commit is contained in:
Nathan Chapman 2021-01-29 15:29:40 -07:00
parent c434b973cc
commit ffd991c066
20 changed files with 383 additions and 67 deletions

View File

@ -8,6 +8,7 @@ django = "*"
django-compressor = "*"
qrcode = "*"
pillow = "*"
django-qr-code = "*"
[dev-packages]

18
Pipfile.lock generated
View File

@ -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",

View File

@ -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)

View File

@ -14,6 +14,6 @@
<input type="submit" value="Save changes" />
</form>
<p><a href="{% url 'password_change' %}">Update password</a></p>
<p><a href="{% url 'password_change' %}">Change password</a></p>
</section>
{% endblock %}

View File

@ -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})
)

View File

@ -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}'

View File

@ -0,0 +1,14 @@
{% extends 'application.html' %}
{% block content %}
<h1>Scan code to clock-in/out</h1>
<section>
<form method="post" action="{% url 'attendance-update' %}">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="clock-in/out">
</form>
</section>
{% endblock %}

View File

@ -1,29 +1,76 @@
<section>
<header>
<p>{{ user.instructor.department.name }}</p>
<a href="">Update profile</a>
<h1>{{ user.instructor.department.name }}</h1>
<p>
<a href="">Generate Reports</a>
</p>
</header>
<h1>{{ user.first_name }} {{ user.last_name }}</h1>
</section>
<article>
<h2>Attendance log</h2>
<h3>Active sessions</h3>
<table>
<tbody>
{% for student in students %}
<thead>
<tr>
<td>{{ student.user.first_name }}</td>
{% for period in student.period_set.all %}
<th>Student</th>
<th>Station</th>
<th>Clocked in</th>
<th colspan="2">Duration</th>
</tr>
</thead>
<tbody>
{% for period in period_list %}
{% if not period.clocked_out %}
<tr>
<td>{{ period.student }}</td>
<td>{{ period.station_number }}</td>
<td>{{ period.clocked_in }}</td>
{% if period.clocked_out %}
<td>{{ period.clocked_out }}</td>
<td>{{ period.clocked_in|timesince:period.clocked_out }}</td>
{% else %}
<td>You have not clocked out.</td>
<td>Current sesson: {{ period.clocked_in|timesince }}</td>
{% endif %}
{% endfor %}
<td><a href="{% url 'period-detail' period.id %}">View</a></td>
</tr>
{% endif %}
{% empty %}
<tr><td colspan="2">No periods yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<article>
<div>
<h3>Attendance log</h3>
<table>
<thead>
<tr>
<th>Student</th>
<th>Station</th>
<th>Clocked in</th>
<th>Clocked out</th>
<th colspan="2">Duration</th>
</tr>
</thead>
<tbody>
{% for period in period_list %}
{% if period.clocked_out %}
<tr>
<td>{{ period.student }}</td>
<td>{{ period.station_number }}</td>
<td>{{ period.clocked_in }}</td>
{% if period.clocked_out %}
<td>{{ period.clocked_out }}</td>
<td>{{ period.clocked_in|timesince:period.clocked_out }}</td>
{% else %}
<td colspan="2">Current sesson: {{ period.clocked_in|timesince }}</td>
{% endif %}
<td><a href="{% url 'period-detail' period.id %}">View</a></td>
</tr>
{% endif %}
{% empty %}
<tr><td colspan="2">No periods yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</article>

View File

@ -1,30 +1,48 @@
<section>
<header>
<p>{{ user.student.department.name }}</p>
<h1>{{ user.first_name }} {{ user.last_name }}</h1>
<a href="{% url 'account-update' user.id %}">Update profile</a>
</header>
<h1>{{ user.first_name }} {{ user.last_name }}</h1>
<h5>{{ user.student.department.name }}</h5>
{% if user.student.is_clocked_in %}
<p>You are currently clocked in.</p>
<a href="">Clock out ></a>
<p>You are currently clocked in.<br>
Current sesson: {{ period_list.first.clocked_in|timesince }}</p>
<a href="{% url 'code-update' user.student.code_set.last.id %}">Clock out →</a>
{% elif user.student.code_set.last %}
<p>You are not clocked in.</p>
<a href="{% url 'code-update' user.student.code_set.last.id %}">Clock in →</a>
{% else %}
<p>You are not clocked in.</p>
<a href="">Clock in ></a>
<a href="{% url 'code-create' %}">Clock in →</a>
{% endif %}
</section>
<article>
<h2>Attendance log</h2>
<p>Total hours for the month: {{period_total}}<br>
<small>(Does not include current session.)</small></p>
<table>
<tbody>
{% for period in user.student.period_set.all %}
<thead>
<tr>
<th>Station</th>
<th>Clocked in</th>
<th>Clocked out</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{% for period in period_list %}
{% if period.clocked_out %}
<tr>
<td>{{ period.station_number }}</td>
<td>{{ period.clocked_in }}</td>
{% if period.clocked_out %}
<td>{{ period.clocked_out }}</td>
<td>{{ period.clocked_in|timesince:period.clocked_out }}</td>
{% else %}
<td>You have not clocked out.</td>
<td colspan="2">Current sesson: {{ period.clocked_in|timesince }}</td>
{% endif %}
</tr>
{% endif %}
{% empty %}
<tr><td colspan="2">No periods yet.</td></tr>
{% endfor %}

View File

@ -0,0 +1,14 @@
{% extends 'application.html' %}
{% load qr_code %}
{% block content %}
<h1>Clock in</h1>
<section>
<p>
{% qr_from_text code.qr_code_str size=24 version=2 %}
</p>
<p><em>Scan code at clock-in station</em></p>
<a href="{% url 'attendance-overview' %}">Done</a>
</section>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'application.html' %}
{% block content %}
<h1>Generate code</h1>
<section>
<form method="post" action="{% url 'code-create' %}">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Generate code" />
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'application.html' %}
{% block content %}
<h1>Generate code</h1>
<section>
<form method="post" action="{% url 'code-update' code.id %}">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Generate code" />
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends 'application.html' %}
{% load static %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
<input type="submit" value="Confirm">
</form>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends 'application.html' %}
{% block content %}
<h1>Period</h1>
<section>
<header>
<a href="{% url 'attendance-overview' %}">← Back</a>
<div>
<a href="{% url 'period-update' period.id %}">Edit</a>
<a href="{% url 'period-delete' period.id %}">Delete</a>
</div>
</header>
<dl>
<dt>Student</dt>
<dd>{{ period.student }}</dd>
<dt>Clocked in</dt>
<dd>{{ period.clocked_in }}</dd>
<dt>Clocked out</dt>
<dd>{{ period.clocked_out }}</dd>
</dl>
</section>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'application.html' %}
{% block content %}
<h1>Generate Period</h1>
<section>
<form method="post" action="{% url 'period-update' period.id %}">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Save changes" />
</form>
</section>
{% endblock %}

View File

@ -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('<int:pk>/', 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('periods/<int:pk>/', 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/<int:pk>/', views.CodeDetailView.as_view(), name='code-detail'),
path('codes/<int:pk>/update/', views.CodeUpdateView.as_view(), name='code-update'),
]

View File

@ -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})

View File

@ -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;}

View File

@ -2,7 +2,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tracker</title>
<title>BTech Tracker</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="chrome=1">
@ -13,9 +13,10 @@
</head>
<body>
<header>
<h4>Tracker</h4>
<h4>BTech Time Tracker</h4>
{% if user.is_authenticated %}
<nav>
<a href="{% url 'account-update' user.id %}">{{ user.first_name }} {{ user.last_name }}</a> /
<a class="logout" href="{% url 'logout' %}">Logout</a>
</nav>
{% else %}
@ -33,6 +34,13 @@
<main>
{% block content %}
{% endblock %}
{% if messages %}
<div class="messages">
{% for message in messages %}
<p {% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
</main>
<footer>

View File

@ -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'