From b82aa37625b6716fdc5c3a56de02a84577ce1486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 15 May 2024 16:52:56 +0200 Subject: [PATCH 01/19] add two new field to agreement These fileds indicate whether an user has an agreement of a particular type. This change is done to support the upcoming agreement change which allow for a user to either use bocken, the electric bike *hornet* or both. --- src/bocken/admin/agreement_admin.py | 1 + src/bocken/forms/agreement_form.py | 3 ++- ...greement_agreement_bocken_type_and_more.py | 23 +++++++++++++++++++ src/bocken/models/agreement.py | 12 ++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py diff --git a/src/bocken/admin/agreement_admin.py b/src/bocken/admin/agreement_admin.py index ca2c12f..1041d3d 100644 --- a/src/bocken/admin/agreement_admin.py +++ b/src/bocken/admin/agreement_admin.py @@ -10,6 +10,7 @@ class AgreementAdmin(ImportExportModelAdmin): form = AgreementForm list_display = ( 'name', 'personnummer', 'phonenumber', + 'agreement_hornet_type', 'agreement_bocken_type', 'email', 'expires_colored' ) search_fields = ['name', 'personnummer'] diff --git a/src/bocken/forms/agreement_form.py b/src/bocken/forms/agreement_form.py index a66d004..4b55e05 100644 --- a/src/bocken/forms/agreement_form.py +++ b/src/bocken/forms/agreement_form.py @@ -26,5 +26,6 @@ class Meta: model = Agreement fields = [ 'name', 'personnummer', 'phonenumber', - 'email', 'agreement_file', 'expires' + 'email', 'agreement_bocken_type', 'agreement_hornet_type', + 'agreement_file', 'expires' ] diff --git a/src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py b/src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py new file mode 100644 index 0000000..3c50e81 --- /dev/null +++ b/src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.1 on 2024-05-15 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bocken', '0004_alter_t_numbers'), + ] + + operations = [ + migrations.AddField( + model_name='agreement', + name='agreement_bocken_type', + field=models.BooleanField(default=False, verbose_name='Agreement for bocken'), + ), + migrations.AddField( + model_name='agreement', + name='agreement_hornet_type', + field=models.BooleanField(default=False, verbose_name='Agreement for hornet'), + ), + ] diff --git a/src/bocken/models/agreement.py b/src/bocken/models/agreement.py index 70654ed..e09859d 100644 --- a/src/bocken/models/agreement.py +++ b/src/bocken/models/agreement.py @@ -80,6 +80,18 @@ class Agreement(models.Model): blank=True ) + agreement_bocken_type = models.BooleanField( + verbose_name=_("Agreement for Bocken"), + default=False, + help_text=_("Designates whether user has a agreement which applies for Bocken or not.") + ) + + agreement_hornet_type = models.BooleanField( + verbose_name=_("Agreement for Hornet"), + default=False, + help_text=_("Designates whether user has a agreement which applies for Hornet or not.") + ) + expires = models.DateField( verbose_name=_("Valid until"), default=get_default_expires, From 6a9b2b65ebac3e2e3cc2e4fae298ac22ce15b4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 15 May 2024 16:55:39 +0200 Subject: [PATCH 02/19] add validation logic to journalentry A user which has an agreement type which does not match that of bocken cannot create a journal entry, if not checked a person with only a agreement that applies to hornet could add entries which would be incorrect. --- src/bocken/forms/journal_entry_form.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index 47232c7..ba2f668 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -124,8 +124,19 @@ def clean(self): # noqa if person_nummer: try: agreement = Agreement.objects.get( - personnummer=person_nummer + personnummer=person_nummer, ) + valid_bocken_agreement = agreement.agreement_bocken_type # TODO: add check for me here :)) + + #Validation logic for checking against the type of agreement the individual has + if not valid_bocken_agreement: + self.add_error('personnummer',_( + "You don't have the correct type of agreement " + "to add an entry for bocken. Contact the head of " + "the pub crew with a new agreement to be able to " + "drive bocken." + )) + self.instance.agreement = agreement except Agreement.DoesNotExist: self.add_error('personnummer', _( From 4963f2a520314d2b876f3b1d986c6b90647bb559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 15 May 2024 17:22:59 +0200 Subject: [PATCH 03/19] translations --- src/bocken/locale/sv/LC_MESSAGES/django.po | 83 ++++++++++++++++------ 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/src/bocken/locale/sv/LC_MESSAGES/django.po b/src/bocken/locale/sv/LC_MESSAGES/django.po index 753da55..570a019 100644 --- a/src/bocken/locale/sv/LC_MESSAGES/django.po +++ b/src/bocken/locale/sv/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-12-14 15:54+0100\n" +"POT-Creation-Date: 2024-05-15 16:35+0200\n" "PO-Revision-Date: 2022-12-14 15:56+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -46,17 +46,17 @@ msgstr "Lösenorden matchar inte" msgid "Change the user's password here." msgstr "Ändra användarens lösenord här." -#: bocken/forms/agreement_expire_form.py:15 +#: bocken/forms/agreement_expire_form.py:17 #: bocken/forms/journal_entry_form.py:24 msgid "Your personnummer" msgstr "Ditt personnummer" -#: bocken/forms/agreement_form.py:15 bocken/models/agreement.py:51 +#: bocken/forms/agreement_form.py:15 bocken/models/agreement.py:67 msgid "The person's private email. Should not be an email ending in @utn.se." msgstr "" "Personens privata email. Ska inte vara en email som slutar med @utn.se." -#: bocken/forms/agreement_form.py:22 bocken/models/agreement.py:63 +#: bocken/forms/agreement_form.py:22 bocken/models/agreement.py:79 msgid "Signed agreement" msgstr "Signerat köravtal" @@ -95,7 +95,22 @@ msgid "Trip meter at start must be larger than the last entry in the journal" msgstr "" "Tripmätare vid start måste vara större än det senaste inlägget i körjournalen" -#: bocken/forms/journal_entry_form.py:132 +#: bocken/forms/journal_entry_form.py:134 +#, fuzzy +#| msgid "" +#| "You don't have a written agreement which you must have to drive bocken. " +#| "Contact the head of the pub crew and send a copy of the details you wrote " +#| "into the fields below." +msgid "" +"You don't have the correct type of agreement to add an entry for bocken. " +"Contact the head of the pub crew with a new agreement to be able to drive " +"bocken." +msgstr "" +"Du har inte skrivit de avtal som du måsta ha för att köra bocken. Kontakta " +"UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " +"fälten nedan." + +#: bocken/forms/journal_entry_form.py:143 msgid "" "You don't have a written agreement which you must have to drive bocken. " "Contact the head of the pub crew and send a copy of the details you wrote " @@ -105,7 +120,7 @@ msgstr "" "UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " "fälten nedan." -#: bocken/forms/journal_entry_form.py:142 +#: bocken/forms/journal_entry_form.py:153 msgid "Trip meter at stop must be larger than the trip meter at start" msgstr "Tripmätare vid stop måste vara större än trippmätare vid start" @@ -130,40 +145,60 @@ msgstr "Administratör" msgid "Admins" msgstr "Administratörer" -#: bocken/models/agreement.py:28 bocken/models/journal_entry_group.py:10 +#: bocken/models/agreement.py:44 bocken/models/journal_entry_group.py:10 #: bocken/templates/journalentry_create.html:57 msgid "Name" msgstr "Namn" -#: bocken/models/agreement.py:29 +#: bocken/models/agreement.py:45 msgid "First and last name" msgstr "För- och efternamn" -#: bocken/models/agreement.py:41 +#: bocken/models/agreement.py:57 msgid "Phonenumber" msgstr "Telefonnummer" -#: bocken/models/agreement.py:68 +#: bocken/models/agreement.py:84 +#, fuzzy +#| msgid "Agreement" +msgid "Agreement for Bocken" +msgstr "Avtal för Bocken" + +#: bocken/models/agreement.py:86 +msgid "Designates whether user has a agreement which applies for Bocken or not." +msgstr ""Avgör ifall en användare har ett avtal som gäller Hornet eller ej. + +#: bocken/models/agreement.py:90 +#, fuzzy +#| msgid "Agreement" +msgid "Agreement for Hornet" +msgstr "Avtal för Hornet" + +#: bocken/models/agreement.py:92 +msgid "Designates whether user has a agreement which applies for Hornet or not." +msgstr "Avgör ifall en användare har ett avtal som gäller Hornet eller ej." + +#: bocken/models/agreement.py:96 msgid "Valid until" msgstr "Giltig t.o.m" -#: bocken/models/agreement.py:70 +#: bocken/models/agreement.py:98 msgid "Agreements are valid for 1 year by default." msgstr "Avtal är giltiga i 1 år som standard." -#: bocken/models/agreement.py:74 bocken/models/journal_entry.py:20 +#: bocken/models/agreement.py:102 bocken/models/journal_entry.py:20 msgid "Agreement" msgstr "Köravtal" -#: bocken/models/agreement.py:75 +#: bocken/models/agreement.py:103 msgid "Agreements" msgstr "Köravtal" -#: bocken/models/agreement.py:114 +#: bocken/models/agreement.py:142 msgid "Reminder to update your Bocken agreement" msgstr "Påminnelse för att uppdatera ditt Bockenavtal" -#: bocken/models/agreement.py:116 +#: bocken/models/agreement.py:144 msgid "" "This is an automated message from UTN:s journal system for Bocken. You are " "receiving this email because you have a Bocken agreement that will expire in " @@ -543,22 +578,24 @@ msgstr "" msgid "Choose a main group" msgstr "Välj en huvudgrupp" -#: bocken/templates/two_level_select.html:59 +#: bocken/templates/two_level_select.html:69 msgid "Choose your group" msgstr "Välj din grupp" -#: bocken/validators.py:22 +#: bocken/validators.py:8 +msgid "Invalid personnummer" +msgstr "Ogiltigt personnummer" + +#: bocken/validators.py:9 msgid "Invalid phonenumber" msgstr "Ogiltigt telefonnummer" -#: bocken/validators.py:42 -msgid "Your personnummer must be on the format YYYYMMDD-XXXX" +#: bocken/validators.py:59 +#, fuzzy +#| msgid "Your personnummer must be on the format YYYYMMDD-XXXX" +msgid "Your personnummer must be on the format YYYYMMDDXXXX" msgstr "Ditt personnummer måste vara på formen YYYYMMDD-XXXX" -#: bocken/validators.py:52 -msgid "Invalid personnummer" -msgstr "Ogiltigt personnummer" - #: bocken/views.py:31 #, python-format msgid "" From 0146ebeead697aa3848d70798c82733a1bdc2ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 26 Jun 2024 16:12:01 +0200 Subject: [PATCH 04/19] wip: add new model+admin made migration to initiliaze data and such but no real work in replacing the logic in journal entries and such --- src/bocken/admin/__init__.py | 4 +- src/bocken/admin/vehicle_admin.py | 9 + src/bocken/migrations/0005_vehicle.py | 51 ++++ ...e_options_journalentry_vehicle_and_more.py | 33 +++ src/bocken/models/models/__init__.py | 10 + src/bocken/models/models/admin.py | 48 ++++ src/bocken/models/models/agreement.py | 162 ++++++++++++ src/bocken/models/models/journal_entry.py | 144 ++++++++++ .../models/models/journal_entry_group.py | 33 +++ src/bocken/models/models/report.py | 248 ++++++++++++++++++ src/bocken/models/models/vehicle.py | 26 ++ 11 files changed, 767 insertions(+), 1 deletion(-) create mode 100644 src/bocken/admin/vehicle_admin.py create mode 100644 src/bocken/migrations/0005_vehicle.py create mode 100644 src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py create mode 100644 src/bocken/models/models/__init__.py create mode 100644 src/bocken/models/models/admin.py create mode 100644 src/bocken/models/models/agreement.py create mode 100644 src/bocken/models/models/journal_entry.py create mode 100644 src/bocken/models/models/journal_entry_group.py create mode 100644 src/bocken/models/models/report.py create mode 100644 src/bocken/models/models/vehicle.py diff --git a/src/bocken/admin/__init__.py b/src/bocken/admin/__init__.py index 507a893..874fb39 100644 --- a/src/bocken/admin/__init__.py +++ b/src/bocken/admin/__init__.py @@ -1,10 +1,11 @@ from bocken.models import ( - Admin, Agreement, JournalEntry, Report, JournalEntryGroup + Admin, Agreement, JournalEntry, Report, JournalEntryGroup, Vehicle ) from .agreement_admin import AgreementAdmin from .journal_entry_admin import JournalEntryAdmin from .journal_entry_group_admin import JournalEntryGroupAdmin from .report_admin import ReportAdmin +from .vehicle_admin import VehicleAdmin from .user_admin import UserAdmin from django.utils.translation import gettext_lazy as _ from django.contrib import admin @@ -16,6 +17,7 @@ admin.site.register(JournalEntry, JournalEntryAdmin) admin.site.register(Report, ReportAdmin) admin.site.register(JournalEntryGroup, JournalEntryGroupAdmin) +admin.site.register(Vehicle, VehicleAdmin) admin.site.unregister(Group) diff --git a/src/bocken/admin/vehicle_admin.py b/src/bocken/admin/vehicle_admin.py new file mode 100644 index 0000000..1c0b3b5 --- /dev/null +++ b/src/bocken/admin/vehicle_admin.py @@ -0,0 +1,9 @@ + +from django.contrib.admin import ModelAdmin + + +class VehicleAdmin(ModelAdmin): + """Custom class for the admin pages for JournalEntryGroup.""" + + list_display = ("vehicle_type", "vehicle_meter_start", "vehicle_meter_stop") + list_filter = ['vehicle_type'] diff --git a/src/bocken/migrations/0005_vehicle.py b/src/bocken/migrations/0005_vehicle.py new file mode 100644 index 0000000..c06023c --- /dev/null +++ b/src/bocken/migrations/0005_vehicle.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.1 on 2024-06-26 11:29 + +from django.db import migrations, models +from django.core.exceptions import ObjectDoesNotExist + +all_vehicles = [ + # Bocken last values as per 26/06/24: 37684 37697 + {"vehicle_type" : "Bocken", "vehicle_meter_start": 37684, "vehicle_meter_stop": 37697}, + {"vehicle_type" : "Bjällran", "vehicle_meter_start": 0, "vehicle_meter_stop": 0} +] + +def add_default_vehicles(apps, schema_editor): + model = apps.get_model("bocken", "Vehicle") + for vehicle in all_vehicles: + created_vehicle = model.objects.create( + vehicle_type=vehicle['vehicle_type'], + vehicle_meter_start = vehicle['vehicle_meter_start'], + vehicle_meter_stop = vehicle['vehicle_meter_stop'], + ) + + +def remove_default_vehicles(apps, schema_editor): + model = apps.get_model("bocken", "Vehicle") + for vehicle in all_vehicles: + try: + vehicle_to_delete = model.objects.get(vehicle_type=vehicle['vehicle_type']).delete() + except ObjectDoesNotExist: + continue + +class Migration(migrations.Migration): + + dependencies = [ + ('bocken', '0004_alter_t_numbers'), + ] + + operations = [ + migrations.CreateModel( + name='Vehicle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vehicle_type', models.CharField(max_length=60, verbose_name='Type')), + ('vehicle_meter_start',models.PositiveIntegerField(default=0,verbose_name='Meter at start')), + ('vehicle_meter_stop',models.PositiveIntegerField(default=0,verbose_name='Meter at stop')), + ], + options={ + 'verbose_name': 'Vehicle type', + 'verbose_name_plural': 'Vehicle types', + }, + ), + migrations.RunPython(add_default_vehicles, remove_default_vehicles) + ] diff --git a/src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py b/src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py new file mode 100644 index 0000000..b3fa3fc --- /dev/null +++ b/src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.1 on 2024-06-26 12:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bocken', '0005_vehicle'), + ] + + operations = [ + migrations.AlterModelOptions( + name='vehicle', + options={'verbose_name': 'Vehicle', 'verbose_name_plural': 'Vehicles'}, + ), + migrations.AddField( + model_name='journalentry', + name='vehicle', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bocken.vehicle', verbose_name='Vehicle'), + ), + migrations.AlterField( + model_name='vehicle', + name='vehicle_meter_start', + field=models.PositiveIntegerField(default=0, verbose_name='Meter at start (Latest Entry)'), + ), + migrations.AlterField( + model_name='vehicle', + name='vehicle_meter_stop', + field=models.PositiveIntegerField(default=0, verbose_name='Meter at stop (Latest Entry)'), + ), + ] diff --git a/src/bocken/models/models/__init__.py b/src/bocken/models/models/__init__.py new file mode 100644 index 0000000..33dc62c --- /dev/null +++ b/src/bocken/models/models/__init__.py @@ -0,0 +1,10 @@ +from .admin import Admin +from .agreement import Agreement +from .journal_entry_group import JournalEntryGroup +from .journal_entry import JournalEntry +from .report import Report +from .vehicle import Vehicle + +__all__ = [ + 'Admin', 'Agreement', 'JournalEntryGroup', 'Report', 'JournalEntry', 'Vehicle' +] diff --git a/src/bocken/models/models/admin.py b/src/bocken/models/models/admin.py new file mode 100644 index 0000000..ad5c136 --- /dev/null +++ b/src/bocken/models/models/admin.py @@ -0,0 +1,48 @@ +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.contrib.auth import get_user_model +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class AdminUserManager(BaseUserManager): + """The UserManager for our Admin class.""" + + def create_user(self, email, password): + """Create a user and return it.""" + user = get_user_model().objects.create(email=email) + user.set_password(password) + user.save() + return user + + def create_superuser(self, email, password): + """ + Create a superuser and return it. + + Since all admins are superusers, a superuser is just a normal user + """ + return self.create_user(email, password) + + +class Admin(AbstractBaseUser): + """Our custom user model for the administrators.""" + + email = models.EmailField(primary_key=True) + is_staff = models.BooleanField(default=True) + is_superuser = models.BooleanField(default=True) + + USERNAME_FIELD = 'email' + EMAIL_FIELD = 'email' + + objects = AdminUserManager() + + class Meta: + verbose_name = _("Admin") + verbose_name_plural = _("Admins") + + def has_perm(self, perm, obj=None): + """Django requires this function.""" + return True + + def has_module_perms(self, app_label): + """Django requires this function.""" + return True diff --git a/src/bocken/models/models/agreement.py b/src/bocken/models/models/agreement.py new file mode 100644 index 0000000..70654ed --- /dev/null +++ b/src/bocken/models/models/agreement.py @@ -0,0 +1,162 @@ +from django.db import models +from bocken.validators import validate_phonenumber, validate_personnummer +from ..utils import format_personnummer, mark_admin_list_cell +from ..fields import PhonenumberField +from django.utils.translation import gettext_lazy as _ +from django.utils.timezone import now, timedelta +from django.template.defaultfilters import date +from django.core.mail import send_mass_mail +from django.conf import settings +from import_export import resources + + +def get_default_expires(): + """Return the default expires date for an agreement.""" + return now().date() + timedelta(days=365) + + +class AgreementManager(models.Manager): + """Manager for the Agreement model.""" + + def get(self, **kwargs): + """Override the default get.""" + # Before each lookup the personnummer is formatted to match + # the format in the database + if 'personnummer' in kwargs: + kwargs['personnummer'] = format_personnummer( + kwargs['personnummer'] + ) + return super().get(**kwargs) + + +class Agreement(models.Model): + """ + Represents a person who has signed a bocken-agreement. + + When a person has signed a bocken-agreement they can drive bocken + for 1 year. After that year they have to sign a new agreement. + """ + + objects = AgreementManager() + + name = models.CharField( + max_length=120, + verbose_name=_("Name"), + help_text=_("First and last name") + ) + + personnummer = models.CharField( + max_length=13, + validators=[validate_personnummer], + unique=True + ) + + phonenumber = PhonenumberField( + max_length=20, + validators=[validate_phonenumber], + verbose_name=_("Phonenumber") + ) + + # Email is allowed to be blank since we don't have an email address to + # everyone who has an agreement at the time of creation of this system. + # TODO: Remove blank when there is an email address for all + # agreements. Also make email unique=True at the same time + email = models.EmailField( + blank=True, + help_text=_( + "The person's private email. Should not be an email ending in " + "@utn.se." + ), + ) + + # agreement file is allowed to be blank since not everyone has signed the + # latest agreement at the time of creation of this system. Instead of + # scanning all those agreements, they will remain in their folder until + # everyone has signed the new agreement. + # TODO: Remove blank when everyone has an agreement file in the system + agreement_file = models.FileField( + upload_to='agreements/', + verbose_name=_("Signed agreement"), + blank=True + ) + + expires = models.DateField( + verbose_name=_("Valid until"), + default=get_default_expires, + help_text=_("Agreements are valid for 1 year by default.") + ) + + class Meta: + verbose_name = _("Agreement") + verbose_name_plural = _("Agreements") + ordering = ('name',) + indexes = [ + models.Index( + fields=['name', 'personnummer'] + ) + ] + + def __str__(self): # noqa + return "{} - {}".format(self.name, self.personnummer) + + def has_expired(self): + """Check if an agreement has expired.""" + return self.expires < now().date() + + def expires_colored(self): + """ + Color the expires field red if the agreement has expired. + + This is used in the admin pages. + """ + if self.has_expired(): + formatted_date = date(self.expires) # Format the date correctly + return mark_admin_list_cell(formatted_date) + else: + return self.expires + expires_colored.admin_order_field = 'expires' + expires_colored.short_description = expires.verbose_name + + @staticmethod + def send_renewal_reminder_10_days_left(): + """Send an email to all agreements that expire in 10 days.""" + agreements = Agreement.objects.filter( + expires=now().date() + timedelta(days=10) + ) + + emails = agreements.exclude(email=None).values_list('email', flat=True) + + if len(emails) > 0: + subject = _("Reminder to update your Bocken agreement") + message = _( + "This is an automated message from UTN:s journal system for " + "Bocken. You are receiving this email because you have a " + "Bocken agreement that will expire in 10 days. If you want " + "to continue driving Bocken you must contact UTN:s " + "klubbmästare by replying to this email.\n\n" + "If you do not want to continue driving Bocken, you can " + "ignore this email." + ) + message_tuple = \ + subject, message, settings.KLUBBMASTARE_EMAIL, list(emails) + send_mass_mail( + (message_tuple, ) + ) + + def clean(self): # noqa: D102 + validate_personnummer(self.personnummer) + + def save(self, *args, **kwargs): # noqa + self.full_clean() + self.personnummer = format_personnummer(self.personnummer) + super(Agreement, self).save(*args, **kwargs) + + +class AgreementResource(resources.ModelResource): + """The agreement resource for django-import-export.""" + + class Meta: + model = Agreement + exclude = ('id', 'email', 'agreement_file') + import_id_fields = ('personnummer',) + clean_model_instances = True diff --git a/src/bocken/models/models/journal_entry.py b/src/bocken/models/models/journal_entry.py new file mode 100644 index 0000000..616e6c1 --- /dev/null +++ b/src/bocken/models/models/journal_entry.py @@ -0,0 +1,144 @@ +from django.db import models +from django.utils.formats import date_format +from django.utils.translation import gettext_lazy as _ +# Report must be imported like this to avoid circular import +import bocken.models.report as report +from bocken.utils import mark_admin_list_cell + + +class JournalEntry(models.Model): + """ + An entry that drivers adds to the journal. + + When a person with an agreement has driven bocken they create an entry + that describes how far they have driven + """ + + agreement = models.ForeignKey( + "Agreement", + on_delete=models.PROTECT, + verbose_name=_("Agreement") + ) + group = models.ForeignKey( + "JournalEntryGroup", + on_delete=models.PROTECT, + verbose_name=_("Group") + ) + vehicle = models.ForeignKey( + "Vehicle", + on_delete=models.PROTECT, + verbose_name=_("Vehicle"), + null=True + ) + + meter_start = models.PositiveIntegerField( + verbose_name=_("Meter at start") + ) + meter_stop = models.PositiveIntegerField( + verbose_name=_("Meter at stop") + ) + created = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Created') + ) + + class Meta: + verbose_name = _("Journal Entry") + verbose_name_plural = _("Journal Entries") + get_latest_by = "meter_stop" + + def __str__(self): # noqa + return "{} - {}".format( + self.created_formatted, + self.agreement.name + ) + + @property + def created_formatted(self): + """Return the created date in the correct format.""" + return date_format(self.created, format='j F Y H:i') + + def get_total_distance(self): + """Calculate the total distance driven.""" + return self.meter_stop - self.meter_start + get_total_distance.short_description = _("Driven Distance (km)") + + def meter_start_gap_marker(self): + """Mark meter_start cells in the admin view if a gap has occured.""" + try: + previous_entry = JournalEntry.objects.filter( + meter_stop__lt=self.meter_stop + ).latest() + except JournalEntry.DoesNotExist: + return self.meter_start + + if previous_entry.meter_stop != self.meter_start: + return mark_admin_list_cell(self.meter_start) + else: + return self.meter_start + meter_start_gap_marker.admin_order_field = 'meter_start' + meter_start_gap_marker.short_description = meter_start.verbose_name + + def meter_stop_gap_marker(self): + """Mark meter_stop cells in the admin view if a gap has occured.""" + try: + previous_entry = JournalEntry.objects.filter( + meter_stop__gt=self.meter_stop + ).earliest() + except JournalEntry.DoesNotExist: + return self.meter_stop + + if previous_entry.meter_start != self.meter_stop: + return mark_admin_list_cell(self.meter_stop) + else: + return self.meter_stop + meter_stop_gap_marker.admin_order_field = 'meter_stop' + meter_stop_gap_marker.short_description = meter_stop.verbose_name + + @staticmethod + def entries_exists(): + """ + Check if there exists any journal entries. + + Returns True if there are not journal entries in the database, + False otherwise + """ + return JournalEntry.objects.exists() + + @staticmethod + def get_latest_entry(): + """Get the entry that was last created.""" + try: + return JournalEntry.objects.latest() + except JournalEntry.DoesNotExist: + return None + + @staticmethod + def get_entries_between(start, end): + """ + Get all entries between the timestamps start and end. + + Returns all journal entries within the time range. All journal entries + that are equal to start or end are included. + """ + entries = JournalEntry.objects.filter( + created__range=(start, end) + ) + return entries + + @staticmethod + def get_entries_since_last_report_amount(): + """Get the number of new entries since the last report.""" + latest_report = report.Report.get_latest_report() + if latest_report: + entries = JournalEntry.objects.exclude( + created__lte=latest_report.created + ) + return entries.count() + else: + return JournalEntry.objects.count() + + @staticmethod + def get_three_latest_entries(): + """Get the three latest entries.""" + return JournalEntry.objects.order_by("-created")[:3] diff --git a/src/bocken/models/models/journal_entry_group.py b/src/bocken/models/models/journal_entry_group.py new file mode 100644 index 0000000..9b834d6 --- /dev/null +++ b/src/bocken/models/models/journal_entry_group.py @@ -0,0 +1,33 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class JournalEntryGroup(models.Model): + """Represents a group that can be selected in a journal entry.""" + + name = models.CharField( + max_length=60, + verbose_name=_("Name") + ) + main_group = models.CharField( + max_length=30, + choices=( + ( + 'committees_workgroups', + _("UTN's committees and workgroups") + ), + ('lg_and_board', _("UTN's management team and board")), + ('fum', _("FUM")), + ('other_officials', _("Other officials within UTN")), + ('sections', _("Sections")), + ('cooperations', _("Cooperations")), + ), + verbose_name=_("Main group") + ) + + class Meta: + verbose_name = _("Journal entry group") + verbose_name_plural = _("Journal entry groups") + + def __str__(self): # noqa + return self.name diff --git a/src/bocken/models/models/report.py b/src/bocken/models/models/report.py new file mode 100644 index 0000000..e678b90 --- /dev/null +++ b/src/bocken/models/models/report.py @@ -0,0 +1,248 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from . import JournalEntryGroup +from django.template.defaultfilters import date +from django.utils.timezone import localtime +from django.utils import timezone +from django.db.models import Sum +from ..utils import kilometers_to_mil +from django.conf import settings +# Journal entry must be imported like this to avoid circular import +import bocken.models.journal_entry as journal_entry +from dateutil.relativedelta import relativedelta + + +class Report(models.Model): + """ + A report is a collection of journal entries. + + A report is created by adnministrators and shows how much each group has + driven bocken between the first and last journal entry of a report + """ + + first = models.DateTimeField() + + last = models.DateTimeField() + + created = models.DateTimeField( + auto_now_add=True, + verbose_name=_("Created") + ) + + cost_per_mil = models.PositiveIntegerField( + default=settings.COST_PER_MIL_DEFAULT, + verbose_name=_("Cost per mil (kr)"), + help_text=_( + "Each report can have a different cost per mil. This allows " + "the cost per mil to be changed without affecting previous reports" + ) + ) + + class Meta: + verbose_name = _("Report") + verbose_name_plural = _("Reports") + get_latest_by = "created" + + def __str__(self): # noqa + return "{} - {}".format( + date(localtime(self.first), "j F Y H:i"), + date(localtime(self.last), "j F Y H:i") + ) + + def get_entries(self): + """ + Get all journal entries within this report. + + Returns a queryset of journal entries + """ + return journal_entry.JournalEntry.get_entries_between( + self.first, self.last + ) + + def get_total_kilometers_driven(self): + """Return the total kilometers that have been driven in this report.""" + entries = self.get_entries() + first = entries.earliest() + last = entries.latest() + + return last.meter_stop - first.meter_start + + def get_statistics_for_groups(self): + """ + Get statistics for each group that is a part of this report. + + Included in these statistics are + - The group + - Total kilometers driven + - Total mil driven + - Total cost each group has to pay + + Returns list of dicts where each dict has the following structure + { + 'group': JournalEntryGroup instance + 'kilometers': Total kilometers (int), + 'mil': Total mil (int), + 'cost': Total cost (int) + } + """ + entries = self.get_entries() + + # Calulate the total kilometers for each group by performing a + # "group by" query in the database. It can be a bit difficult + # to understand but the documentation has an explanation for it. + # https://docs.djangoproject.com/en/3.1/topics/db/aggregation/#values + # What it does is that it calculates the total distance driven for each + # journal entry and then sums them up for each group, giving us the + # total kilometers for each group. + kilometers_for_groups = entries.values("group").annotate( + total_kilometers=Sum("meter_stop") - Sum("meter_start") + ).order_by("group__name") + + statistics = [] + for group in kilometers_for_groups: + kilometers = group['total_kilometers'] + actual_group = JournalEntryGroup.objects.get( + pk=group['group'] + ) + mil = kilometers_to_mil(kilometers) + cost = self.calculate_cost_for_mil(mil) + + statistics.append({ + 'group': actual_group, + 'kilometers': kilometers, + 'mil': mil, + 'cost': cost + }) + + return statistics + + def get_total_statistics(self): + """ + Get the total values from the statisitcs in this report. + + Returns a dict with the following structure: + { + 'total_kilometers': Total kilometers driven in this report, + 'total_mil': Total mil driven in this report, + 'total_cost': Total cost for all groups in the report + } + """ + statistics_for_groups = self.get_statistics_for_groups() + + total_kilometers = sum( + statistic['kilometers'] for statistic in statistics_for_groups + ) + total_mil = sum( + statistic['mil'] for statistic in statistics_for_groups + ) + total_cost = sum( + statistic['cost'] for statistic in statistics_for_groups + ) + + return { + 'total_kilometers': total_kilometers, + 'total_mil': total_mil, + 'total_cost': total_cost + } + + def calculate_cost_for_mil(self, mil: int): + """ + Calculate the total cost for driving a certain amount of mil. + + Parameters: + mil (int): The total mil driven + + Returns the cost in kr + """ + return mil * self.cost_per_mil + + def calculate_lost_cost(self): + """ + Calculate the lost cost for the report. + + Returns a dict: { + lost_kilometers: the total number of kilometers lost, + lost_cost: the total cost lost + } + """ + total_driven = self.get_total_kilometers_driven() + total_logged = self.get_total_statistics()['total_kilometers'] + + lost_kilometers = total_driven - total_logged + lost_mil = kilometers_to_mil(lost_kilometers) + lost_cost = self.calculate_cost_for_mil(lost_mil) + + return { + 'lost_kilometers': lost_kilometers, + 'lost_cost': lost_cost + } + + @staticmethod + def get_first_for_new_report(): + """ + Get the first journal entry when creating a new report. + + The first entry in a new report is the last entry in the previous + report. If there are no previous reports, the first entry is the + first ever created journal entry. If there are no journal entries, + a report can not be created. + + Returns the journal entry that should be used as the first in a new + report. + """ + latest_report = Report.get_latest_report() + if latest_report: + first = latest_report.last + else: + try: + first = journal_entry.JournalEntry.objects.earliest().created + except journal_entry.JournalEntry.DoesNotExist: + first = None + + return first + + @staticmethod + def get_new_report_details(): + """ + Get all details for a new report. + + Returns a tuple of the first and last journal entry for the new report + and all journal entries between the first and last. + """ + first = Report.get_first_for_new_report() + last = timezone.now() + + entries = journal_entry.JournalEntry.get_entries_between(first, last) + + return first, last, entries + + @staticmethod + def get_latest_report(): + """ + Return the lastest created report. + + If no reports exist, None is returned + """ + try: + return Report.objects.latest() + except Report.DoesNotExist: + return None + + @staticmethod + def delete_old_reports(): + """ + Delete all reports that are older than one year. + + Also deletes all journal entries in those reports. + """ + one_and_half_years_ago = \ + timezone.now() - relativedelta(years=1, months=6) + reports_to_delete = Report.objects.filter( + created__date__lte=one_and_half_years_ago + ) + + # Delete all related journal entries + for report in reports_to_delete: + report.get_entries().delete() + + reports_to_delete.delete() diff --git a/src/bocken/models/models/vehicle.py b/src/bocken/models/models/vehicle.py new file mode 100644 index 0000000..ad1e6a3 --- /dev/null +++ b/src/bocken/models/models/vehicle.py @@ -0,0 +1,26 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Vehicle(models.Model): + """Represents a vehicle that can be selected in a journal entry.""" + + vehicle_type = models.CharField( + max_length=60, + verbose_name=_("Type") + ) + vehicle_meter_start = models.PositiveIntegerField( + verbose_name=_("Meter at start (Latest Entry)"), + default=0 + ) + vehicle_meter_stop = models.PositiveIntegerField( + verbose_name=_("Meter at stop (Latest Entry)"), + default=0 + ) + + class Meta: + verbose_name = _("Vehicle") + verbose_name_plural = _("Vehicles") + + def __str__(self): # noqa + return self.vehicle_type From 5332ad42084cf323ee217610fcd7f205953f25ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Thu, 27 Jun 2024 15:47:11 +0200 Subject: [PATCH 05/19] wip: "works" need to test and further fix the front-end so that the automatically filled trips are fixed --- src/bocken/admin/__init__.py | 2 +- src/bocken/admin/agreement_admin.py | 2 +- src/bocken/admin/journal_entry_admin.py | 4 +- src/bocken/admin/vehicle_admin.py | 4 +- src/bocken/forms/agreement_form.py | 2 +- src/bocken/forms/journal_entry_form.py | 44 +++- .../migrations/0005_add_agreement_types.py | 23 ++ ...greement_agreement_bocken_type_and_more.py | 23 -- src/bocken/migrations/0005_vehicle.py | 51 ---- ...e_options_journalentry_vehicle_and_more.py | 33 --- src/bocken/migrations/0006_vehicle.py | 62 +++++ src/bocken/models/__init__.py | 3 +- src/bocken/models/agreement.py | 15 +- src/bocken/models/journal_entry.py | 21 +- src/bocken/models/models/__init__.py | 10 - src/bocken/models/models/admin.py | 48 ---- src/bocken/models/models/agreement.py | 162 ------------ src/bocken/models/models/journal_entry.py | 144 ---------- .../models/models/journal_entry_group.py | 33 --- src/bocken/models/models/report.py | 248 ------------------ src/bocken/models/{models => }/vehicle.py | 15 +- src/bocken/templates/journalentry_create.html | 4 + 22 files changed, 165 insertions(+), 788 deletions(-) create mode 100644 src/bocken/migrations/0005_add_agreement_types.py delete mode 100644 src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py delete mode 100644 src/bocken/migrations/0005_vehicle.py delete mode 100644 src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py create mode 100644 src/bocken/migrations/0006_vehicle.py delete mode 100644 src/bocken/models/models/__init__.py delete mode 100644 src/bocken/models/models/admin.py delete mode 100644 src/bocken/models/models/agreement.py delete mode 100644 src/bocken/models/models/journal_entry.py delete mode 100644 src/bocken/models/models/journal_entry_group.py delete mode 100644 src/bocken/models/models/report.py rename src/bocken/models/{models => }/vehicle.py (65%) diff --git a/src/bocken/admin/__init__.py b/src/bocken/admin/__init__.py index 874fb39..1df550b 100644 --- a/src/bocken/admin/__init__.py +++ b/src/bocken/admin/__init__.py @@ -14,10 +14,10 @@ admin.site.register(Admin, UserAdmin) admin.site.register(Agreement, AgreementAdmin) +admin.site.register(Vehicle, VehicleAdmin) admin.site.register(JournalEntry, JournalEntryAdmin) admin.site.register(Report, ReportAdmin) admin.site.register(JournalEntryGroup, JournalEntryGroupAdmin) -admin.site.register(Vehicle, VehicleAdmin) admin.site.unregister(Group) diff --git a/src/bocken/admin/agreement_admin.py b/src/bocken/admin/agreement_admin.py index 1041d3d..ec79b1f 100644 --- a/src/bocken/admin/agreement_admin.py +++ b/src/bocken/admin/agreement_admin.py @@ -10,7 +10,7 @@ class AgreementAdmin(ImportExportModelAdmin): form = AgreementForm list_display = ( 'name', 'personnummer', 'phonenumber', - 'agreement_hornet_type', 'agreement_bocken_type', + 'bike_agreement', 'car_agreement', 'email', 'expires_colored' ) search_fields = ['name', 'personnummer'] diff --git a/src/bocken/admin/journal_entry_admin.py b/src/bocken/admin/journal_entry_admin.py index 39fa69e..caea4ea 100644 --- a/src/bocken/admin/journal_entry_admin.py +++ b/src/bocken/admin/journal_entry_admin.py @@ -5,10 +5,10 @@ class JournalEntryAdmin(ModelAdmin): """Custom class for the admin pages for journal entry.""" list_display = ( - 'agreement', 'created', 'group', + 'agreement', 'created', 'group', 'vehicle', 'meter_start_gap_marker', 'meter_stop_gap_marker', 'get_total_distance' ) - ordering = ('-meter_stop', ) + ordering = ('vehicle', '-meter_stop', ) search_fields = [ 'agreement__name', 'agreement__personnummer', 'group__name' ] diff --git a/src/bocken/admin/vehicle_admin.py b/src/bocken/admin/vehicle_admin.py index 1c0b3b5..3e356d6 100644 --- a/src/bocken/admin/vehicle_admin.py +++ b/src/bocken/admin/vehicle_admin.py @@ -5,5 +5,5 @@ class VehicleAdmin(ModelAdmin): """Custom class for the admin pages for JournalEntryGroup.""" - list_display = ("vehicle_type", "vehicle_meter_start", "vehicle_meter_stop") - list_filter = ['vehicle_type'] + list_display = ("vehicle_name", "car", "bike", "vehicle_meter_start", "vehicle_meter_stop") + list_filter = ['vehicle_name'] diff --git a/src/bocken/forms/agreement_form.py b/src/bocken/forms/agreement_form.py index 4b55e05..bb21123 100644 --- a/src/bocken/forms/agreement_form.py +++ b/src/bocken/forms/agreement_form.py @@ -26,6 +26,6 @@ class Meta: model = Agreement fields = [ 'name', 'personnummer', 'phonenumber', - 'email', 'agreement_bocken_type', 'agreement_hornet_type', + 'email', 'bike_agreement', 'car_agreement', 'agreement_file', 'expires' ] diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index ba2f668..47d0ec0 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -1,5 +1,6 @@ from bocken.models.agreement import Agreement from bocken.models.journal_entry import JournalEntry +from bocken.models.vehicle import Vehicle from ..validators import validate_personnummer from ..utils import format_personnummer from ..widgets import TwoLevelSelect @@ -35,7 +36,7 @@ class JournalEntryForm(ModelForm): class Meta: model = JournalEntry fields = [ - 'personnummer', 'group', 'meter_start', 'meter_stop' + 'personnummer', 'vehicle', 'group', 'meter_start', 'meter_stop' ] widgets = { "meter_start": TextInput( @@ -57,6 +58,7 @@ class Meta: 'group': 'users', 'meter_start': 'play-circle', 'meter_stop': 'stop-circle', + 'vehicle': 'truck' } help_texts = { 'group': _( @@ -68,6 +70,9 @@ class Meta: "latest entry. If the number is not correct, enter the value " "that the meter had when you started driving. Also inform the " "head of the pub crew about this." + ), + 'vehicle': _( + "Choose the type of vehicle you have driven." ) } @@ -103,7 +108,8 @@ def clean_personnummer(self): def clean_meter_start(self): """Meter start must be larger than the meter stop in the last entry.""" - latest_entry = JournalEntry.get_latest_entry() + form_vehicle = self.cleaned_data['vehicle'] + latest_entry = JournalEntry.get_latest_entry(form_vehicle) if latest_entry: if latest_entry.meter_stop > self.cleaned_data['meter_start']: raise ValidationError(_( @@ -124,18 +130,27 @@ def clean(self): # noqa if person_nummer: try: agreement = Agreement.objects.get( - personnummer=person_nummer, + personnummer=person_nummer ) - valid_bocken_agreement = agreement.agreement_bocken_type # TODO: add check for me here :)) - - #Validation logic for checking against the type of agreement the individual has - if not valid_bocken_agreement: - self.add_error('personnummer',_( - "You don't have the correct type of agreement " - "to add an entry for bocken. Contact the head of " - "the pub crew with a new agreement to be able to " - "drive bocken." - )) + can_use_car = agreement.car_agreement + can_use_bike = agreement.bike_agreement + veh = self.cleaned_data.get('vehicle') + if veh.car: + if not can_use_car: + self.add_error('vehicle', _( + "You don't have a written agreement which you must have " + "to drive a car. Contact the head of the pub crew and " + "send a copy of the details you wrote into the fields " + "below." + )) + else: + if not can_use_bike: + self.add_error('vehicle', _( + "You don't have a written agreement which you must have " + "to drive a bike. Contact the head of the pub crew and " + "send a copy of the details you wrote into the fields " + "below." + )) self.instance.agreement = agreement except Agreement.DoesNotExist: @@ -148,10 +163,11 @@ def clean(self): # noqa # Make sure meter stop is larger than meter start meter_start = cleaned_data.get('meter_start', 0) + meter_stop = cleaned_data['meter_stop'] if cleaned_data['meter_stop'] <= meter_start: self.add_error('meter_stop', _( "Trip meter at stop must be larger than the trip meter at " "start" )) - + Vehicle.objects.filter(id=veh.id).update(vehicle_meter_start = meter_start, vehicle_meter_stop = meter_stop) return cleaned_data diff --git a/src/bocken/migrations/0005_add_agreement_types.py b/src/bocken/migrations/0005_add_agreement_types.py new file mode 100644 index 0000000..a05e9e0 --- /dev/null +++ b/src/bocken/migrations/0005_add_agreement_types.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.1 on 2024-05-15 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bocken', '0004_alter_t_numbers'), + ] + + operations = [ + migrations.AddField( + model_name='agreement', + name='bike_agreement', + field=models.BooleanField(default=False, help_text='Designates whether user has a agreement which applies for bikes or not.', verbose_name='Agreement for bikes'), + ), + migrations.AddField( + model_name='agreement', + name='car_agreement', + field=models.BooleanField(default=False, help_text='Designates whether user has a agreement which applies for cars or not.', verbose_name='Agreement for cars'), + ) + ] diff --git a/src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py b/src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py deleted file mode 100644 index 3c50e81..0000000 --- a/src/bocken/migrations/0005_agreement_agreement_bocken_type_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.1 on 2024-05-15 12:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bocken', '0004_alter_t_numbers'), - ] - - operations = [ - migrations.AddField( - model_name='agreement', - name='agreement_bocken_type', - field=models.BooleanField(default=False, verbose_name='Agreement for bocken'), - ), - migrations.AddField( - model_name='agreement', - name='agreement_hornet_type', - field=models.BooleanField(default=False, verbose_name='Agreement for hornet'), - ), - ] diff --git a/src/bocken/migrations/0005_vehicle.py b/src/bocken/migrations/0005_vehicle.py deleted file mode 100644 index c06023c..0000000 --- a/src/bocken/migrations/0005_vehicle.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 4.2.1 on 2024-06-26 11:29 - -from django.db import migrations, models -from django.core.exceptions import ObjectDoesNotExist - -all_vehicles = [ - # Bocken last values as per 26/06/24: 37684 37697 - {"vehicle_type" : "Bocken", "vehicle_meter_start": 37684, "vehicle_meter_stop": 37697}, - {"vehicle_type" : "Bjällran", "vehicle_meter_start": 0, "vehicle_meter_stop": 0} -] - -def add_default_vehicles(apps, schema_editor): - model = apps.get_model("bocken", "Vehicle") - for vehicle in all_vehicles: - created_vehicle = model.objects.create( - vehicle_type=vehicle['vehicle_type'], - vehicle_meter_start = vehicle['vehicle_meter_start'], - vehicle_meter_stop = vehicle['vehicle_meter_stop'], - ) - - -def remove_default_vehicles(apps, schema_editor): - model = apps.get_model("bocken", "Vehicle") - for vehicle in all_vehicles: - try: - vehicle_to_delete = model.objects.get(vehicle_type=vehicle['vehicle_type']).delete() - except ObjectDoesNotExist: - continue - -class Migration(migrations.Migration): - - dependencies = [ - ('bocken', '0004_alter_t_numbers'), - ] - - operations = [ - migrations.CreateModel( - name='Vehicle', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('vehicle_type', models.CharField(max_length=60, verbose_name='Type')), - ('vehicle_meter_start',models.PositiveIntegerField(default=0,verbose_name='Meter at start')), - ('vehicle_meter_stop',models.PositiveIntegerField(default=0,verbose_name='Meter at stop')), - ], - options={ - 'verbose_name': 'Vehicle type', - 'verbose_name_plural': 'Vehicle types', - }, - ), - migrations.RunPython(add_default_vehicles, remove_default_vehicles) - ] diff --git a/src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py b/src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py deleted file mode 100644 index b3fa3fc..0000000 --- a/src/bocken/migrations/0006_alter_vehicle_options_journalentry_vehicle_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.1 on 2024-06-26 12:58 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bocken', '0005_vehicle'), - ] - - operations = [ - migrations.AlterModelOptions( - name='vehicle', - options={'verbose_name': 'Vehicle', 'verbose_name_plural': 'Vehicles'}, - ), - migrations.AddField( - model_name='journalentry', - name='vehicle', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bocken.vehicle', verbose_name='Vehicle'), - ), - migrations.AlterField( - model_name='vehicle', - name='vehicle_meter_start', - field=models.PositiveIntegerField(default=0, verbose_name='Meter at start (Latest Entry)'), - ), - migrations.AlterField( - model_name='vehicle', - name='vehicle_meter_stop', - field=models.PositiveIntegerField(default=0, verbose_name='Meter at stop (Latest Entry)'), - ), - ] diff --git a/src/bocken/migrations/0006_vehicle.py b/src/bocken/migrations/0006_vehicle.py new file mode 100644 index 0000000..703609c --- /dev/null +++ b/src/bocken/migrations/0006_vehicle.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.1 on 2024-06-26 11:29 + +from django.db import migrations, models +from django.core.exceptions import ObjectDoesNotExist +import django.db.models.deletion + +all_vehicles = [ + # Bocken last values as per 26/06/24: 37684 37697 + {"vehicle_name" : "Bocken", "car": True, "bike": False, "vehicle_meter_start": 37684, "vehicle_meter_stop": 37697}, + {"vehicle_name" : "Bjällran", "car": False, "bike": True, "vehicle_meter_start": 0, "vehicle_meter_stop": 0} +] + +def add_default_vehicles(apps, schema_editor): + model = apps.get_model("bocken", "Vehicle") + for vehicle in all_vehicles: + created_vehicle = model.objects.create( + vehicle_name=vehicle['vehicle_name'], + vehicle_meter_start = vehicle['vehicle_meter_start'], + vehicle_meter_stop = vehicle['vehicle_meter_stop'], + car = vehicle['car'], + bike = vehicle['bike'], + ) + + +def remove_default_vehicles(apps, schema_editor): + model = apps.get_model("bocken", "Vehicle") + for vehicle in all_vehicles: + try: + vehicle_to_delete = model.objects.get(vehicle_name=vehicle['vehicle_name']).delete() + except ObjectDoesNotExist: + continue + +class Migration(migrations.Migration): + + dependencies = [ + ('bocken', '0005_add_agreement_types'), + ] + + operations = [ + migrations.CreateModel( + name='Vehicle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vehicle_name', models.CharField(max_length=60, verbose_name='Name')), + ('car',models.BooleanField(default=False,verbose_name='Is the vehicle a car')), + ('bike',models.BooleanField(default=False,verbose_name='Is the vehicle a bike')), + ('vehicle_meter_start',models.PositiveIntegerField(default=0,verbose_name='Meter at start (Latest Entry)')), + ('vehicle_meter_stop',models.PositiveIntegerField(default=0,verbose_name='Meter at stop (Latest Entry)')), + ], + options={ + 'verbose_name': 'Vehicle', + 'verbose_name_plural': 'Vehicles', + }, + ), + migrations.AddField( + model_name='journalentry', + name='vehicle', + # default here ensures all old entries are set to bocken :) + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='bocken.vehicle', verbose_name='Vehicle'), + ), + migrations.RunPython(add_default_vehicles, remove_default_vehicles) + ] diff --git a/src/bocken/models/__init__.py b/src/bocken/models/__init__.py index c80fdd4..33dc62c 100644 --- a/src/bocken/models/__init__.py +++ b/src/bocken/models/__init__.py @@ -3,7 +3,8 @@ from .journal_entry_group import JournalEntryGroup from .journal_entry import JournalEntry from .report import Report +from .vehicle import Vehicle __all__ = [ - 'Admin', 'Agreement', 'JournalEntryGroup', 'Report', 'JournalEntry' + 'Admin', 'Agreement', 'JournalEntryGroup', 'Report', 'JournalEntry', 'Vehicle' ] diff --git a/src/bocken/models/agreement.py b/src/bocken/models/agreement.py index e09859d..325deab 100644 --- a/src/bocken/models/agreement.py +++ b/src/bocken/models/agreement.py @@ -79,19 +79,20 @@ class Agreement(models.Model): verbose_name=_("Signed agreement"), blank=True ) - - agreement_bocken_type = models.BooleanField( - verbose_name=_("Agreement for Bocken"), + car_agreement = models.BooleanField( + verbose_name=_("Agreement for cars"), default=False, - help_text=_("Designates whether user has a agreement which applies for Bocken or not.") + help_text=_("Designates whether user has a agreement which applies for cars or not.") ) - agreement_hornet_type = models.BooleanField( - verbose_name=_("Agreement for Hornet"), + bike_agreement = models.BooleanField( + verbose_name=_("Agreement for bikes"), default=False, - help_text=_("Designates whether user has a agreement which applies for Hornet or not.") + help_text=_("Designates whether user has a agreement which applies for bikes or not.") ) + + expires = models.DateField( verbose_name=_("Valid until"), default=get_default_expires, diff --git a/src/bocken/models/journal_entry.py b/src/bocken/models/journal_entry.py index 417484d..5b87dfd 100644 --- a/src/bocken/models/journal_entry.py +++ b/src/bocken/models/journal_entry.py @@ -24,6 +24,13 @@ class JournalEntry(models.Model): on_delete=models.PROTECT, verbose_name=_("Group") ) + vehicle = models.ForeignKey( + "Vehicle", + on_delete=models.PROTECT, + verbose_name=_("Vehicle"), + default=1 + ) + meter_start = models.PositiveIntegerField( verbose_name=_("Meter at start") ) @@ -56,11 +63,13 @@ def get_total_distance(self): return self.meter_stop - self.meter_start get_total_distance.short_description = _("Driven Distance (km)") + # TODO: Refactor following functions to just check diff between vehicle of the same type def meter_start_gap_marker(self): """Mark meter_start cells in the admin view if a gap has occured.""" try: previous_entry = JournalEntry.objects.filter( - meter_stop__lt=self.meter_stop + meter_stop__lt=self.meter_stop, + vehicle=self.vehicle ).latest() except JournalEntry.DoesNotExist: return self.meter_start @@ -76,7 +85,8 @@ def meter_stop_gap_marker(self): """Mark meter_stop cells in the admin view if a gap has occured.""" try: previous_entry = JournalEntry.objects.filter( - meter_stop__gt=self.meter_stop + meter_stop__gt=self.meter_stop, + vehicle=self.vehicle ).earliest() except JournalEntry.DoesNotExist: return self.meter_stop @@ -99,10 +109,13 @@ def entries_exists(): return JournalEntry.objects.exists() @staticmethod - def get_latest_entry(): + def get_latest_entry(vehicle_type = None): """Get the entry that was last created.""" try: - return JournalEntry.objects.latest() + if vehicle_type: + return JournalEntry.objects.filter(vehicle=vehicle_type).latest() + else: + return JournalEntry.objects.latest() except JournalEntry.DoesNotExist: return None diff --git a/src/bocken/models/models/__init__.py b/src/bocken/models/models/__init__.py deleted file mode 100644 index 33dc62c..0000000 --- a/src/bocken/models/models/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .admin import Admin -from .agreement import Agreement -from .journal_entry_group import JournalEntryGroup -from .journal_entry import JournalEntry -from .report import Report -from .vehicle import Vehicle - -__all__ = [ - 'Admin', 'Agreement', 'JournalEntryGroup', 'Report', 'JournalEntry', 'Vehicle' -] diff --git a/src/bocken/models/models/admin.py b/src/bocken/models/models/admin.py deleted file mode 100644 index ad5c136..0000000 --- a/src/bocken/models/models/admin.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.contrib.auth.models import AbstractBaseUser, BaseUserManager -from django.contrib.auth import get_user_model -from django.db import models -from django.utils.translation import gettext_lazy as _ - - -class AdminUserManager(BaseUserManager): - """The UserManager for our Admin class.""" - - def create_user(self, email, password): - """Create a user and return it.""" - user = get_user_model().objects.create(email=email) - user.set_password(password) - user.save() - return user - - def create_superuser(self, email, password): - """ - Create a superuser and return it. - - Since all admins are superusers, a superuser is just a normal user - """ - return self.create_user(email, password) - - -class Admin(AbstractBaseUser): - """Our custom user model for the administrators.""" - - email = models.EmailField(primary_key=True) - is_staff = models.BooleanField(default=True) - is_superuser = models.BooleanField(default=True) - - USERNAME_FIELD = 'email' - EMAIL_FIELD = 'email' - - objects = AdminUserManager() - - class Meta: - verbose_name = _("Admin") - verbose_name_plural = _("Admins") - - def has_perm(self, perm, obj=None): - """Django requires this function.""" - return True - - def has_module_perms(self, app_label): - """Django requires this function.""" - return True diff --git a/src/bocken/models/models/agreement.py b/src/bocken/models/models/agreement.py deleted file mode 100644 index 70654ed..0000000 --- a/src/bocken/models/models/agreement.py +++ /dev/null @@ -1,162 +0,0 @@ -from django.db import models -from bocken.validators import validate_phonenumber, validate_personnummer -from ..utils import format_personnummer, mark_admin_list_cell -from ..fields import PhonenumberField -from django.utils.translation import gettext_lazy as _ -from django.utils.timezone import now, timedelta -from django.template.defaultfilters import date -from django.core.mail import send_mass_mail -from django.conf import settings -from import_export import resources - - -def get_default_expires(): - """Return the default expires date for an agreement.""" - return now().date() + timedelta(days=365) - - -class AgreementManager(models.Manager): - """Manager for the Agreement model.""" - - def get(self, **kwargs): - """Override the default get.""" - # Before each lookup the personnummer is formatted to match - # the format in the database - if 'personnummer' in kwargs: - kwargs['personnummer'] = format_personnummer( - kwargs['personnummer'] - ) - return super().get(**kwargs) - - -class Agreement(models.Model): - """ - Represents a person who has signed a bocken-agreement. - - When a person has signed a bocken-agreement they can drive bocken - for 1 year. After that year they have to sign a new agreement. - """ - - objects = AgreementManager() - - name = models.CharField( - max_length=120, - verbose_name=_("Name"), - help_text=_("First and last name") - ) - - personnummer = models.CharField( - max_length=13, - validators=[validate_personnummer], - unique=True - ) - - phonenumber = PhonenumberField( - max_length=20, - validators=[validate_phonenumber], - verbose_name=_("Phonenumber") - ) - - # Email is allowed to be blank since we don't have an email address to - # everyone who has an agreement at the time of creation of this system. - # TODO: Remove blank when there is an email address for all - # agreements. Also make email unique=True at the same time - email = models.EmailField( - blank=True, - help_text=_( - "The person's private email. Should not be an email ending in " - "@utn.se." - ), - ) - - # agreement file is allowed to be blank since not everyone has signed the - # latest agreement at the time of creation of this system. Instead of - # scanning all those agreements, they will remain in their folder until - # everyone has signed the new agreement. - # TODO: Remove blank when everyone has an agreement file in the system - agreement_file = models.FileField( - upload_to='agreements/', - verbose_name=_("Signed agreement"), - blank=True - ) - - expires = models.DateField( - verbose_name=_("Valid until"), - default=get_default_expires, - help_text=_("Agreements are valid for 1 year by default.") - ) - - class Meta: - verbose_name = _("Agreement") - verbose_name_plural = _("Agreements") - ordering = ('name',) - indexes = [ - models.Index( - fields=['name', 'personnummer'] - ) - ] - - def __str__(self): # noqa - return "{} - {}".format(self.name, self.personnummer) - - def has_expired(self): - """Check if an agreement has expired.""" - return self.expires < now().date() - - def expires_colored(self): - """ - Color the expires field red if the agreement has expired. - - This is used in the admin pages. - """ - if self.has_expired(): - formatted_date = date(self.expires) # Format the date correctly - return mark_admin_list_cell(formatted_date) - else: - return self.expires - expires_colored.admin_order_field = 'expires' - expires_colored.short_description = expires.verbose_name - - @staticmethod - def send_renewal_reminder_10_days_left(): - """Send an email to all agreements that expire in 10 days.""" - agreements = Agreement.objects.filter( - expires=now().date() + timedelta(days=10) - ) - - emails = agreements.exclude(email=None).values_list('email', flat=True) - - if len(emails) > 0: - subject = _("Reminder to update your Bocken agreement") - message = _( - "This is an automated message from UTN:s journal system for " - "Bocken. You are receiving this email because you have a " - "Bocken agreement that will expire in 10 days. If you want " - "to continue driving Bocken you must contact UTN:s " - "klubbmästare by replying to this email.\n\n" - "If you do not want to continue driving Bocken, you can " - "ignore this email." - ) - message_tuple = \ - subject, message, settings.KLUBBMASTARE_EMAIL, list(emails) - send_mass_mail( - (message_tuple, ) - ) - - def clean(self): # noqa: D102 - validate_personnummer(self.personnummer) - - def save(self, *args, **kwargs): # noqa - self.full_clean() - self.personnummer = format_personnummer(self.personnummer) - super(Agreement, self).save(*args, **kwargs) - - -class AgreementResource(resources.ModelResource): - """The agreement resource for django-import-export.""" - - class Meta: - model = Agreement - exclude = ('id', 'email', 'agreement_file') - import_id_fields = ('personnummer',) - clean_model_instances = True diff --git a/src/bocken/models/models/journal_entry.py b/src/bocken/models/models/journal_entry.py deleted file mode 100644 index 616e6c1..0000000 --- a/src/bocken/models/models/journal_entry.py +++ /dev/null @@ -1,144 +0,0 @@ -from django.db import models -from django.utils.formats import date_format -from django.utils.translation import gettext_lazy as _ -# Report must be imported like this to avoid circular import -import bocken.models.report as report -from bocken.utils import mark_admin_list_cell - - -class JournalEntry(models.Model): - """ - An entry that drivers adds to the journal. - - When a person with an agreement has driven bocken they create an entry - that describes how far they have driven - """ - - agreement = models.ForeignKey( - "Agreement", - on_delete=models.PROTECT, - verbose_name=_("Agreement") - ) - group = models.ForeignKey( - "JournalEntryGroup", - on_delete=models.PROTECT, - verbose_name=_("Group") - ) - vehicle = models.ForeignKey( - "Vehicle", - on_delete=models.PROTECT, - verbose_name=_("Vehicle"), - null=True - ) - - meter_start = models.PositiveIntegerField( - verbose_name=_("Meter at start") - ) - meter_stop = models.PositiveIntegerField( - verbose_name=_("Meter at stop") - ) - created = models.DateTimeField( - auto_now_add=True, - verbose_name=_('Created') - ) - - class Meta: - verbose_name = _("Journal Entry") - verbose_name_plural = _("Journal Entries") - get_latest_by = "meter_stop" - - def __str__(self): # noqa - return "{} - {}".format( - self.created_formatted, - self.agreement.name - ) - - @property - def created_formatted(self): - """Return the created date in the correct format.""" - return date_format(self.created, format='j F Y H:i') - - def get_total_distance(self): - """Calculate the total distance driven.""" - return self.meter_stop - self.meter_start - get_total_distance.short_description = _("Driven Distance (km)") - - def meter_start_gap_marker(self): - """Mark meter_start cells in the admin view if a gap has occured.""" - try: - previous_entry = JournalEntry.objects.filter( - meter_stop__lt=self.meter_stop - ).latest() - except JournalEntry.DoesNotExist: - return self.meter_start - - if previous_entry.meter_stop != self.meter_start: - return mark_admin_list_cell(self.meter_start) - else: - return self.meter_start - meter_start_gap_marker.admin_order_field = 'meter_start' - meter_start_gap_marker.short_description = meter_start.verbose_name - - def meter_stop_gap_marker(self): - """Mark meter_stop cells in the admin view if a gap has occured.""" - try: - previous_entry = JournalEntry.objects.filter( - meter_stop__gt=self.meter_stop - ).earliest() - except JournalEntry.DoesNotExist: - return self.meter_stop - - if previous_entry.meter_start != self.meter_stop: - return mark_admin_list_cell(self.meter_stop) - else: - return self.meter_stop - meter_stop_gap_marker.admin_order_field = 'meter_stop' - meter_stop_gap_marker.short_description = meter_stop.verbose_name - - @staticmethod - def entries_exists(): - """ - Check if there exists any journal entries. - - Returns True if there are not journal entries in the database, - False otherwise - """ - return JournalEntry.objects.exists() - - @staticmethod - def get_latest_entry(): - """Get the entry that was last created.""" - try: - return JournalEntry.objects.latest() - except JournalEntry.DoesNotExist: - return None - - @staticmethod - def get_entries_between(start, end): - """ - Get all entries between the timestamps start and end. - - Returns all journal entries within the time range. All journal entries - that are equal to start or end are included. - """ - entries = JournalEntry.objects.filter( - created__range=(start, end) - ) - return entries - - @staticmethod - def get_entries_since_last_report_amount(): - """Get the number of new entries since the last report.""" - latest_report = report.Report.get_latest_report() - if latest_report: - entries = JournalEntry.objects.exclude( - created__lte=latest_report.created - ) - return entries.count() - else: - return JournalEntry.objects.count() - - @staticmethod - def get_three_latest_entries(): - """Get the three latest entries.""" - return JournalEntry.objects.order_by("-created")[:3] diff --git a/src/bocken/models/models/journal_entry_group.py b/src/bocken/models/models/journal_entry_group.py deleted file mode 100644 index 9b834d6..0000000 --- a/src/bocken/models/models/journal_entry_group.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ - - -class JournalEntryGroup(models.Model): - """Represents a group that can be selected in a journal entry.""" - - name = models.CharField( - max_length=60, - verbose_name=_("Name") - ) - main_group = models.CharField( - max_length=30, - choices=( - ( - 'committees_workgroups', - _("UTN's committees and workgroups") - ), - ('lg_and_board', _("UTN's management team and board")), - ('fum', _("FUM")), - ('other_officials', _("Other officials within UTN")), - ('sections', _("Sections")), - ('cooperations', _("Cooperations")), - ), - verbose_name=_("Main group") - ) - - class Meta: - verbose_name = _("Journal entry group") - verbose_name_plural = _("Journal entry groups") - - def __str__(self): # noqa - return self.name diff --git a/src/bocken/models/models/report.py b/src/bocken/models/models/report.py deleted file mode 100644 index e678b90..0000000 --- a/src/bocken/models/models/report.py +++ /dev/null @@ -1,248 +0,0 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ -from . import JournalEntryGroup -from django.template.defaultfilters import date -from django.utils.timezone import localtime -from django.utils import timezone -from django.db.models import Sum -from ..utils import kilometers_to_mil -from django.conf import settings -# Journal entry must be imported like this to avoid circular import -import bocken.models.journal_entry as journal_entry -from dateutil.relativedelta import relativedelta - - -class Report(models.Model): - """ - A report is a collection of journal entries. - - A report is created by adnministrators and shows how much each group has - driven bocken between the first and last journal entry of a report - """ - - first = models.DateTimeField() - - last = models.DateTimeField() - - created = models.DateTimeField( - auto_now_add=True, - verbose_name=_("Created") - ) - - cost_per_mil = models.PositiveIntegerField( - default=settings.COST_PER_MIL_DEFAULT, - verbose_name=_("Cost per mil (kr)"), - help_text=_( - "Each report can have a different cost per mil. This allows " - "the cost per mil to be changed without affecting previous reports" - ) - ) - - class Meta: - verbose_name = _("Report") - verbose_name_plural = _("Reports") - get_latest_by = "created" - - def __str__(self): # noqa - return "{} - {}".format( - date(localtime(self.first), "j F Y H:i"), - date(localtime(self.last), "j F Y H:i") - ) - - def get_entries(self): - """ - Get all journal entries within this report. - - Returns a queryset of journal entries - """ - return journal_entry.JournalEntry.get_entries_between( - self.first, self.last - ) - - def get_total_kilometers_driven(self): - """Return the total kilometers that have been driven in this report.""" - entries = self.get_entries() - first = entries.earliest() - last = entries.latest() - - return last.meter_stop - first.meter_start - - def get_statistics_for_groups(self): - """ - Get statistics for each group that is a part of this report. - - Included in these statistics are - - The group - - Total kilometers driven - - Total mil driven - - Total cost each group has to pay - - Returns list of dicts where each dict has the following structure - { - 'group': JournalEntryGroup instance - 'kilometers': Total kilometers (int), - 'mil': Total mil (int), - 'cost': Total cost (int) - } - """ - entries = self.get_entries() - - # Calulate the total kilometers for each group by performing a - # "group by" query in the database. It can be a bit difficult - # to understand but the documentation has an explanation for it. - # https://docs.djangoproject.com/en/3.1/topics/db/aggregation/#values - # What it does is that it calculates the total distance driven for each - # journal entry and then sums them up for each group, giving us the - # total kilometers for each group. - kilometers_for_groups = entries.values("group").annotate( - total_kilometers=Sum("meter_stop") - Sum("meter_start") - ).order_by("group__name") - - statistics = [] - for group in kilometers_for_groups: - kilometers = group['total_kilometers'] - actual_group = JournalEntryGroup.objects.get( - pk=group['group'] - ) - mil = kilometers_to_mil(kilometers) - cost = self.calculate_cost_for_mil(mil) - - statistics.append({ - 'group': actual_group, - 'kilometers': kilometers, - 'mil': mil, - 'cost': cost - }) - - return statistics - - def get_total_statistics(self): - """ - Get the total values from the statisitcs in this report. - - Returns a dict with the following structure: - { - 'total_kilometers': Total kilometers driven in this report, - 'total_mil': Total mil driven in this report, - 'total_cost': Total cost for all groups in the report - } - """ - statistics_for_groups = self.get_statistics_for_groups() - - total_kilometers = sum( - statistic['kilometers'] for statistic in statistics_for_groups - ) - total_mil = sum( - statistic['mil'] for statistic in statistics_for_groups - ) - total_cost = sum( - statistic['cost'] for statistic in statistics_for_groups - ) - - return { - 'total_kilometers': total_kilometers, - 'total_mil': total_mil, - 'total_cost': total_cost - } - - def calculate_cost_for_mil(self, mil: int): - """ - Calculate the total cost for driving a certain amount of mil. - - Parameters: - mil (int): The total mil driven - - Returns the cost in kr - """ - return mil * self.cost_per_mil - - def calculate_lost_cost(self): - """ - Calculate the lost cost for the report. - - Returns a dict: { - lost_kilometers: the total number of kilometers lost, - lost_cost: the total cost lost - } - """ - total_driven = self.get_total_kilometers_driven() - total_logged = self.get_total_statistics()['total_kilometers'] - - lost_kilometers = total_driven - total_logged - lost_mil = kilometers_to_mil(lost_kilometers) - lost_cost = self.calculate_cost_for_mil(lost_mil) - - return { - 'lost_kilometers': lost_kilometers, - 'lost_cost': lost_cost - } - - @staticmethod - def get_first_for_new_report(): - """ - Get the first journal entry when creating a new report. - - The first entry in a new report is the last entry in the previous - report. If there are no previous reports, the first entry is the - first ever created journal entry. If there are no journal entries, - a report can not be created. - - Returns the journal entry that should be used as the first in a new - report. - """ - latest_report = Report.get_latest_report() - if latest_report: - first = latest_report.last - else: - try: - first = journal_entry.JournalEntry.objects.earliest().created - except journal_entry.JournalEntry.DoesNotExist: - first = None - - return first - - @staticmethod - def get_new_report_details(): - """ - Get all details for a new report. - - Returns a tuple of the first and last journal entry for the new report - and all journal entries between the first and last. - """ - first = Report.get_first_for_new_report() - last = timezone.now() - - entries = journal_entry.JournalEntry.get_entries_between(first, last) - - return first, last, entries - - @staticmethod - def get_latest_report(): - """ - Return the lastest created report. - - If no reports exist, None is returned - """ - try: - return Report.objects.latest() - except Report.DoesNotExist: - return None - - @staticmethod - def delete_old_reports(): - """ - Delete all reports that are older than one year. - - Also deletes all journal entries in those reports. - """ - one_and_half_years_ago = \ - timezone.now() - relativedelta(years=1, months=6) - reports_to_delete = Report.objects.filter( - created__date__lte=one_and_half_years_ago - ) - - # Delete all related journal entries - for report in reports_to_delete: - report.get_entries().delete() - - reports_to_delete.delete() diff --git a/src/bocken/models/models/vehicle.py b/src/bocken/models/vehicle.py similarity index 65% rename from src/bocken/models/models/vehicle.py rename to src/bocken/models/vehicle.py index ad1e6a3..e3afde0 100644 --- a/src/bocken/models/models/vehicle.py +++ b/src/bocken/models/vehicle.py @@ -5,9 +5,18 @@ class Vehicle(models.Model): """Represents a vehicle that can be selected in a journal entry.""" - vehicle_type = models.CharField( + vehicle_name = models.CharField( max_length=60, - verbose_name=_("Type") + verbose_name=_("Name") + ) + + car = models.BooleanField( + verbose_name=_("Is the vehicle a car"), + default=False, + ) + bike = models.BooleanField( + verbose_name=_("Is the vehicle a bike"), + default=False, ) vehicle_meter_start = models.PositiveIntegerField( verbose_name=_("Meter at start (Latest Entry)"), @@ -23,4 +32,4 @@ class Meta: verbose_name_plural = _("Vehicles") def __str__(self): # noqa - return self.vehicle_type + return self.vehicle_name diff --git a/src/bocken/templates/journalentry_create.html b/src/bocken/templates/journalentry_create.html index 65c4c26..1a04a83 100644 --- a/src/bocken/templates/journalentry_create.html +++ b/src/bocken/templates/journalentry_create.html @@ -57,6 +57,10 @@

{% translate 'Previous entries' %}

{% translate 'Name' %} {{ entry.agreement.name }} + + {% translate 'Vehicle' %} + {{ entry.vehicle }} + {% translate 'Group' %} {{ entry.group }} From 07480ae2cea5a2e3d8cd4d5786b1eb5f1ccb5438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Fri, 28 Jun 2024 16:03:46 +0200 Subject: [PATCH 06/19] wip: update template Added code for fetching the latest status of all of the vehicles dynamically so that it mimics previous behaviour with only a single vehicle --- src/bocken/forms/journal_entry_form.py | 18 ++++++++--- src/bocken/models/journal_entry.py | 11 +++++-- src/bocken/templates/journalentry_create.html | 32 ++++++++++++++++++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index 47d0ec0..2a04e11 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -79,13 +79,21 @@ class Meta: def __init__(self, *args, **kwargs): super(JournalEntryForm, self).__init__(*args, **kwargs) # Set the initial value for the meter start to the stop value of the - # last entry since it most likely is the value of the meter when a - # person starts driving. - latest_entry = JournalEntry.get_latest_entry() - if latest_entry: + # last entry based on the current vehicle choice since it most + # likely is the value of the meter when a person starts driving. + all_vehicles = Vehicle.objects.all() + latest_entries = [JournalEntry.get_latest_entry(x) for x in all_vehicles] + if latest_entries: + latest_entry = latest_entries[0] self.initial = { - 'meter_start': latest_entry.meter_stop + 'meter_start': latest_entry.meter_stop, } + # This stores all of the latest registered trips for each available vehicle. + # By doing this we can hence "support" any amount of vehicle and fetch + # their latest trip to automatically set as a value for when + # a user is registering a new journal entry + for item in latest_entries: + self.initial[f'meter_start_{str(item.vehicle).lower()}'] = item.meter_stop # If there is data from the previous form (a.k.a. invalid data # was passed) we need to add some of that data to the TwoLevelSelect diff --git a/src/bocken/models/journal_entry.py b/src/bocken/models/journal_entry.py index 5b87dfd..319dfbe 100644 --- a/src/bocken/models/journal_entry.py +++ b/src/bocken/models/journal_entry.py @@ -110,7 +110,10 @@ def entries_exists(): @staticmethod def get_latest_entry(vehicle_type = None): - """Get the entry that was last created.""" + """ + Get the entry that was last created based on the vehicle, + if no vehicle is supplied the latest trip in general is fetched. + """ try: if vehicle_type: return JournalEntry.objects.filter(vehicle=vehicle_type).latest() @@ -120,7 +123,7 @@ def get_latest_entry(vehicle_type = None): return None @staticmethod - def get_entries_between(start, end): + def get_entries_between(start, end, vehicle_type = None): """ Get all entries between the timestamps start and end. @@ -128,8 +131,10 @@ def get_entries_between(start, end): that are equal to start or end are included. """ entries = JournalEntry.objects.filter( - created__range=(start, end) + created__range=(start, end), + vehicle = vehicle_type if vehicle_type != None else 1 ) + return entries @staticmethod diff --git a/src/bocken/templates/journalentry_create.html b/src/bocken/templates/journalentry_create.html index 1a04a83..410dfd3 100644 --- a/src/bocken/templates/journalentry_create.html +++ b/src/bocken/templates/journalentry_create.html @@ -23,6 +23,19 @@ {% elif field == form.captcha %} {{field}} + {% elif field == form.meter_start %} +
+ + {{ field|add_class:"flex-1 flex justify-center font-extralight text-lg min-h-3 pl-8" }} +
{% else %}
@@ -38,7 +51,6 @@

{{ field.help_text }}

{% endfor %} -

@@ -103,6 +115,24 @@

{% translate 'Previous entries' %}

document.querySelector("#nr-kilometers").innerHTML = value; } + document.getElementById("id_vehicle").addEventListener("change", getSpecificVehicleValue); + function getSpecificVehicleValue(){ + const v_id = document.getElementById("id_vehicle").value; + const meter_start_container_elem = document.getElementById("id_meter_start_container"); + const meter_start_elem = document.getElementById("id_meter_start"); + switch(v_id) { + case "2": //bike + const bike_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_bjällran")); + meter_start_elem.value = bike_meter; + break; + case "1": //car + const car_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_bocken")); + meter_start_elem.value = car_meter; + break; + default: + console.log("Couldn't identify the type of vehicle choose, silently failing :(") + } + } {% endblock %} From dabe81e20683b91a67e92884771a29b99197793b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Tue, 9 Jul 2024 15:57:14 +0200 Subject: [PATCH 07/19] wip: add main group and vehicle to report form Show the main group and the vehicle driven for the reports so that it is "easier" to collect statistics. --- src/bocken/models/report.py | 93 +++++++++---------- .../templates/admin/change_report_form.html | 8 +- 2 files changed, 51 insertions(+), 50 deletions(-) diff --git a/src/bocken/models/report.py b/src/bocken/models/report.py index e678b90..050610c 100644 --- a/src/bocken/models/report.py +++ b/src/bocken/models/report.py @@ -1,15 +1,18 @@ +from dateutil.relativedelta import relativedelta +from django.conf import settings from django.db import models -from django.utils.translation import gettext_lazy as _ -from . import JournalEntryGroup +from django.db.models import Sum from django.template.defaultfilters import date -from django.utils.timezone import localtime from django.utils import timezone -from django.db.models import Sum -from ..utils import kilometers_to_mil -from django.conf import settings +from django.utils.timezone import localtime +from django.utils.translation import gettext_lazy as _ + # Journal entry must be imported like this to avoid circular import import bocken.models.journal_entry as journal_entry -from dateutil.relativedelta import relativedelta +import bocken.models.vehicle as vehicle + +from ..utils import kilometers_to_mil +from . import JournalEntryGroup class Report(models.Model): @@ -24,10 +27,7 @@ class Report(models.Model): last = models.DateTimeField() - created = models.DateTimeField( - auto_now_add=True, - verbose_name=_("Created") - ) + created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) cost_per_mil = models.PositiveIntegerField( default=settings.COST_PER_MIL_DEFAULT, @@ -35,7 +35,7 @@ class Report(models.Model): help_text=_( "Each report can have a different cost per mil. This allows " "the cost per mil to be changed without affecting previous reports" - ) + ), ) class Meta: @@ -46,18 +46,17 @@ class Meta: def __str__(self): # noqa return "{} - {}".format( date(localtime(self.first), "j F Y H:i"), - date(localtime(self.last), "j F Y H:i") + date(localtime(self.last), "j F Y H:i"), ) + # TODO: Refactor all of the following code to have either a car or bike report (necessary?) def get_entries(self): """ Get all journal entries within this report. Returns a queryset of journal entries """ - return journal_entry.JournalEntry.get_entries_between( - self.first, self.last - ) + return journal_entry.JournalEntry.get_entries_between(self.first, self.last) def get_total_kilometers_driven(self): """Return the total kilometers that have been driven in this report.""" @@ -67,6 +66,7 @@ def get_total_kilometers_driven(self): return last.meter_stop - first.meter_start + #TODO: Refactor this method to fetch for ALL Vehicle types and not just normal ones def get_statistics_for_groups(self): """ Get statistics for each group that is a part of this report. @@ -86,7 +86,7 @@ def get_statistics_for_groups(self): } """ entries = self.get_entries() - + print(entries) # Calulate the total kilometers for each group by performing a # "group by" query in the database. It can be a bit difficult # to understand but the documentation has an explanation for it. @@ -94,25 +94,30 @@ def get_statistics_for_groups(self): # What it does is that it calculates the total distance driven for each # journal entry and then sums them up for each group, giving us the # total kilometers for each group. - kilometers_for_groups = entries.values("group").annotate( - total_kilometers=Sum("meter_stop") - Sum("meter_start") - ).order_by("group__name") + kilometers_for_groups = ( + entries.values("group", "vehicle") + .annotate(total_kilometers=Sum("meter_stop") - Sum("meter_start")) + .order_by("group__main_group","group__name") + ) statistics = [] + #TODO: Add vehicle stats here? for group in kilometers_for_groups: - kilometers = group['total_kilometers'] - actual_group = JournalEntryGroup.objects.get( - pk=group['group'] - ) + kilometers = group["total_kilometers"] + actual_group = JournalEntryGroup.objects.get(pk=group["group"]) mil = kilometers_to_mil(kilometers) cost = self.calculate_cost_for_mil(mil) - - statistics.append({ - 'group': actual_group, - 'kilometers': kilometers, - 'mil': mil, - 'cost': cost - }) + actual_vehicle = vehicle.Vehicle.objects.get(pk=group["vehicle"]) + statistics.append( + { + "group": actual_group, + "main_group": actual_group.get_main_group_display, + "kilometers": kilometers, + "mil": mil, + "cost": cost, + "vehicle": actual_vehicle, + } + ) return statistics @@ -130,19 +135,15 @@ def get_total_statistics(self): statistics_for_groups = self.get_statistics_for_groups() total_kilometers = sum( - statistic['kilometers'] for statistic in statistics_for_groups - ) - total_mil = sum( - statistic['mil'] for statistic in statistics_for_groups - ) - total_cost = sum( - statistic['cost'] for statistic in statistics_for_groups + statistic["kilometers"] for statistic in statistics_for_groups ) + total_mil = sum(statistic["mil"] for statistic in statistics_for_groups) + total_cost = sum(statistic["cost"] for statistic in statistics_for_groups) return { - 'total_kilometers': total_kilometers, - 'total_mil': total_mil, - 'total_cost': total_cost + "total_kilometers": total_kilometers, + "total_mil": total_mil, + "total_cost": total_cost, } def calculate_cost_for_mil(self, mil: int): @@ -166,16 +167,13 @@ def calculate_lost_cost(self): } """ total_driven = self.get_total_kilometers_driven() - total_logged = self.get_total_statistics()['total_kilometers'] + total_logged = self.get_total_statistics()["total_kilometers"] lost_kilometers = total_driven - total_logged lost_mil = kilometers_to_mil(lost_kilometers) lost_cost = self.calculate_cost_for_mil(lost_mil) - return { - 'lost_kilometers': lost_kilometers, - 'lost_cost': lost_cost - } + return {"lost_kilometers": lost_kilometers, "lost_cost": lost_cost} @staticmethod def get_first_for_new_report(): @@ -235,8 +233,7 @@ def delete_old_reports(): Also deletes all journal entries in those reports. """ - one_and_half_years_ago = \ - timezone.now() - relativedelta(years=1, months=6) + one_and_half_years_ago = timezone.now() - relativedelta(years=1, months=6) reports_to_delete = Report.objects.filter( created__date__lte=one_and_half_years_ago ) diff --git a/src/bocken/templates/admin/change_report_form.html b/src/bocken/templates/admin/change_report_form.html index a3ba49f..9dbaa50 100644 --- a/src/bocken/templates/admin/change_report_form.html +++ b/src/bocken/templates/admin/change_report_form.html @@ -8,6 +8,8 @@ + + @@ -16,6 +18,8 @@ {% for statistic in statistics_for_groups %} + + @@ -32,11 +36,11 @@
{% translate 'Group' %}{% translate 'Main Group' %}{% translate 'Vehicle' %} {% translate 'Total kilometers' %} {% translate 'Total mil' %} {% translate 'Total cost' %} ({{ original.cost_per_mil }} kr/mil)
{{ statistic.group }}{{ statistic.main_group }}{{ statistic.vehicle }} {{ statistic.kilometers|intcomma }} km {{ statistic.mil|intcomma }} mil {{ statistic.cost|intcomma }} kr
-

{% translate 'Groups that are not displayed above have not driven bocken during this time period' %}

+

{% translate 'Groups that are not displayed above have not driven any vehicle during this time period' %}

{% translate 'Lost cost' %}

-

{% translate 'Lost costs appear when a group has driven bocken but not filled in the journal afterwards. This means that it is not possible to know who should pay for those kilometers and the cost is therefore "lost".' %}

+

{% translate 'Lost costs appear when a group has driven a vehicle but not filled in the journal afterwards. This means that it is not possible to know who should pay for those kilometers and the cost is therefore "lost".' %}

{% with total_driven=original.get_total_kilometers_driven total_logged=total_statistics.total_kilometers %} From 5fb533630cdb82ad45e903e43955e31d1221213f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Tue, 9 Jul 2024 15:58:59 +0200 Subject: [PATCH 08/19] wip: remove debug print --- src/bocken/models/report.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bocken/models/report.py b/src/bocken/models/report.py index 050610c..d245ded 100644 --- a/src/bocken/models/report.py +++ b/src/bocken/models/report.py @@ -86,7 +86,6 @@ def get_statistics_for_groups(self): } """ entries = self.get_entries() - print(entries) # Calulate the total kilometers for each group by performing a # "group by" query in the database. It can be a bit difficult # to understand but the documentation has an explanation for it. From 43b44277cb2f6e96dcc57153deadb569632634d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Tue, 9 Jul 2024 16:05:21 +0200 Subject: [PATCH 09/19] wip: docs and add todos :shrug: --- src/bocken/models/report.py | 8 ++++---- src/bocken/tests/test_journal_entry_form.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/bocken/models/report.py b/src/bocken/models/report.py index d245ded..bc4b5d8 100644 --- a/src/bocken/models/report.py +++ b/src/bocken/models/report.py @@ -49,7 +49,6 @@ def __str__(self): # noqa date(localtime(self.last), "j F Y H:i"), ) - # TODO: Refactor all of the following code to have either a car or bike report (necessary?) def get_entries(self): """ Get all journal entries within this report. @@ -66,13 +65,13 @@ def get_total_kilometers_driven(self): return last.meter_stop - first.meter_start - #TODO: Refactor this method to fetch for ALL Vehicle types and not just normal ones def get_statistics_for_groups(self): """ Get statistics for each group that is a part of this report. Included in these statistics are - The group + - The main group's display name - Total kilometers driven - Total mil driven - Total cost each group has to pay @@ -80,6 +79,7 @@ def get_statistics_for_groups(self): Returns list of dicts where each dict has the following structure { 'group': JournalEntryGroup instance + 'main_group', Display Name of main_group based on JournalEntryGroup instance 'kilometers': Total kilometers (int), 'mil': Total mil (int), 'cost': Total cost (int) @@ -93,14 +93,14 @@ def get_statistics_for_groups(self): # What it does is that it calculates the total distance driven for each # journal entry and then sums them up for each group, giving us the # total kilometers for each group. + #TODO: Test this without vehicle-values kilometers_for_groups = ( - entries.values("group", "vehicle") + entries.values("group","vehicle") .annotate(total_kilometers=Sum("meter_stop") - Sum("meter_start")) .order_by("group__main_group","group__name") ) statistics = [] - #TODO: Add vehicle stats here? for group in kilometers_for_groups: kilometers = group["total_kilometers"] actual_group = JournalEntryGroup.objects.get(pk=group["group"]) diff --git a/src/bocken/tests/test_journal_entry_form.py b/src/bocken/tests/test_journal_entry_form.py index 49d13ce..10c0553 100644 --- a/src/bocken/tests/test_journal_entry_form.py +++ b/src/bocken/tests/test_journal_entry_form.py @@ -37,7 +37,7 @@ def setUp(self): # noqa 'confirm': True, 'g-recaptcha-response': 'PASSED' } - + #TODO: fixme def test_initial_meter_start(self): """ Test the initial meter start. @@ -54,6 +54,7 @@ def test_initial_meter_start(self): form = JournalEntryForm() self.assertEqual(form.initial['meter_start'], 49) + #TODO: fixme def test_invalid_personnummer(self): """Test form submission with invalid personnummer.""" self.form_data['personnummer'] = '980101-1111' @@ -62,6 +63,7 @@ def test_invalid_personnummer(self): self.assertFalse(form.is_valid()) self.assertTrue('personnummer' in form.errors) + #TODO: fixme def test_too_small_meter_start(self): """Test when meter start is smaller than the latest entry.""" JournalEntry.objects.create( @@ -77,6 +79,7 @@ def test_too_small_meter_start(self): self.assertFalse(form.is_valid()) self.assertTrue('meter_start' in form.errors) + #TODO: fixme def test_too_small_meter_stop(self): """Test when meter stop is smaller than meter start.""" self.form_data['meter_stop'] = 25 @@ -85,6 +88,7 @@ def test_too_small_meter_stop(self): self.assertFalse(form.is_valid()) self.assertTrue('meter_stop' in form.errors) + #TODO: fixme def test_valid_submission(self): """Test a valid submission.""" form = JournalEntryForm(self.form_data) @@ -95,7 +99,7 @@ def test_valid_submission(self): self.assertEqual(latest_entry.agreement, self.agreement) self.assertEqual(latest_entry.meter_start, 45) self.assertEqual(latest_entry.meter_stop, 49) - + #TODO: fixme def test_different_personnummer_format(self): """ Test a personnummer that has a different format. @@ -119,6 +123,7 @@ def test_different_personnummer_format(self): latest_entry = JournalEntry.get_latest_entry() self.assertEqual(latest_entry.agreement, self.agreement) + #TODO: fixme def test_expired_agreement(self): """ Test when a form is submitted with an expired agreement. @@ -148,6 +153,7 @@ def test_expired_agreement(self): settings.KLUBBMASTARE_EMAIL in first_email.recipients() ) + #TODO: fixme def test_gap_notification(self): """Test that an email is sent if a gap occurs.""" # Create an earlier journal entry @@ -176,6 +182,7 @@ def test_gap_notification(self): settings.KLUBBMASTARE_EMAIL in first_email.recipients() ) + #TODO: fixme def test_t_number_wrong_format(self): """Test a submission with the wrong format on the T number.""" self.form_data['personnummer'] = '19980101-T728' @@ -185,6 +192,7 @@ def test_t_number_wrong_format(self): self.assertTrue('personnummer' in form.errors) self.assertIn('YYYYMMDDXXXX', form.errors['personnummer'][0]) + #TODO: fixme def test_t_number(self): """Test submitting with the correct format on T-number.""" self.form_data['personnummer'] = '19980101T728' From 4dce9f552a9899dc0b777bccaa87115d11375936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 10 Jul 2024 15:21:02 +0200 Subject: [PATCH 10/19] wip: added validation logic to form and add tests Updated tests for journalentryform to use vehicle, also added tests which check if the user can or can not drive a particular vehicle etc. --- src/bocken/forms/journal_entry_form.py | 8 +- src/bocken/models/journal_entry.py | 14 +- src/bocken/tests/test_journal_entry_form.py | 193 ++++++++++++++------ 3 files changed, 147 insertions(+), 68 deletions(-) diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index 2a04e11..1f4f3e7 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -82,7 +82,7 @@ def __init__(self, *args, **kwargs): # last entry based on the current vehicle choice since it most # likely is the value of the meter when a person starts driving. all_vehicles = Vehicle.objects.all() - latest_entries = [JournalEntry.get_latest_entry(x) for x in all_vehicles] + latest_entries = [JournalEntry.get_latest_entry(x) for x in all_vehicles if JournalEntry.get_latest_entry(x) != None] if latest_entries: latest_entry = latest_entries[0] self.initial = { @@ -135,6 +135,7 @@ def clean(self): # noqa # we don't need to add an error message that a user does not # have a written agreement. person_nummer = self.cleaned_data.get('personnummer') + veh = self.cleaned_data.get('vehicle') if person_nummer: try: agreement = Agreement.objects.get( @@ -142,7 +143,6 @@ def clean(self): # noqa ) can_use_car = agreement.car_agreement can_use_bike = agreement.bike_agreement - veh = self.cleaned_data.get('vehicle') if veh.car: if not can_use_car: self.add_error('vehicle', _( @@ -177,5 +177,7 @@ def clean(self): # noqa "Trip meter at stop must be larger than the trip meter at " "start" )) - Vehicle.objects.filter(id=veh.id).update(vehicle_meter_start = meter_start, vehicle_meter_stop = meter_stop) + else: + if veh: + Vehicle.objects.filter(id=veh.id).update(vehicle_meter_start = meter_start, vehicle_meter_stop = meter_stop) return cleaned_data diff --git a/src/bocken/models/journal_entry.py b/src/bocken/models/journal_entry.py index 319dfbe..e40ab46 100644 --- a/src/bocken/models/journal_entry.py +++ b/src/bocken/models/journal_entry.py @@ -63,7 +63,6 @@ def get_total_distance(self): return self.meter_stop - self.meter_start get_total_distance.short_description = _("Driven Distance (km)") - # TODO: Refactor following functions to just check diff between vehicle of the same type def meter_start_gap_marker(self): """Mark meter_start cells in the admin view if a gap has occured.""" try: @@ -130,10 +129,15 @@ def get_entries_between(start, end, vehicle_type = None): Returns all journal entries within the time range. All journal entries that are equal to start or end are included. """ - entries = JournalEntry.objects.filter( - created__range=(start, end), - vehicle = vehicle_type if vehicle_type != None else 1 - ) + if vehicle_type: + entries = JournalEntry.objects.filter( + created__range=(start, end), + vehicle = vehicle_type + ) + else: + entries = JournalEntry.objects.filter( + created__range=(start, end) + ) return entries diff --git a/src/bocken/tests/test_journal_entry_form.py b/src/bocken/tests/test_journal_entry_form.py index 10c0553..a7937e3 100644 --- a/src/bocken/tests/test_journal_entry_form.py +++ b/src/bocken/tests/test_journal_entry_form.py @@ -1,11 +1,13 @@ -from django.test import TestCase -from ..forms import JournalEntryForm -from ..models import JournalEntry, Agreement, JournalEntryGroup -from django.utils import timezone from datetime import timedelta + +from django.conf import settings from django.core import mail +from django.test import TestCase from django.urls import reverse -from django.conf import settings +from django.utils import timezone + +from ..forms import JournalEntryForm +from ..models import Agreement, JournalEntry, JournalEntryGroup, Vehicle class JournalEntryFormTestCase(TestCase): @@ -16,28 +18,49 @@ def setUp(self): # noqa name="Name name", personnummer="980101-3039", phonenumber="0733221122", + car_agreement=True, email="mail@mail.se", - expires=timezone.now() + timedelta(days=365) + expires=timezone.now() + timedelta(days=365), ) self.t_number_agreement = Agreement.objects.create( name="Blipp blopp", personnummer="19980101T728", phonenumber="0733221144", + car_agreement=True, + bike_agreement=False, + email="mail2@mail2.se", + ) + self.bike_agreement = Agreement.objects.create( + name="Blipp blopp", + personnummer="189001069815", + phonenumber="0733221144", + car_agreement=False, + bike_agreement=True, + email="mail2@mail2.se", + ) + self.car_agreement = Agreement.objects.create( + name="Blipp blopp", + personnummer="189001019802", + phonenumber="0733221144", + car_agreement=False, + bike_agreement=True, email="mail2@mail2.se", ) self.group = JournalEntryGroup.objects.create( name="Gruppen", - main_group='lg_and_board', + main_group="lg_and_board", ) + self.vehicle = Vehicle.objects.create(vehicle_name="TockenKrocken", car=True) self.form_data = { - 'personnummer': '980101-3039', - 'group': self.group.id, - 'meter_start': 45, - 'meter_stop': 49, - 'confirm': True, - 'g-recaptcha-response': 'PASSED' + "personnummer": "980101-3039", + "group": self.group.id, + "vehicle": self.vehicle.id, # This id correlates to bocken as should be the default for all current journal entries + "meter_start": 45, + "meter_stop": 49, + "confirm": True, + "g-recaptcha-response": "PASSED", } - #TODO: fixme + def test_initial_meter_start(self): """ Test the initial meter start. @@ -46,49 +69,47 @@ def test_initial_meter_start(self): """ JournalEntry.objects.create( agreement=self.agreement, + vehicle=self.vehicle, group=self.group, meter_start=40, - meter_stop=49 + meter_stop=49, ) form = JournalEntryForm() - self.assertEqual(form.initial['meter_start'], 49) + self.assertEqual(form.initial["meter_start"], 49) - #TODO: fixme def test_invalid_personnummer(self): """Test form submission with invalid personnummer.""" - self.form_data['personnummer'] = '980101-1111' + self.form_data["personnummer"] = "980101-1111" form = JournalEntryForm(self.form_data) self.assertFalse(form.is_valid()) - self.assertTrue('personnummer' in form.errors) + self.assertTrue("personnummer" in form.errors) - #TODO: fixme def test_too_small_meter_start(self): """Test when meter start is smaller than the latest entry.""" JournalEntry.objects.create( agreement=self.agreement, + vehicle=self.vehicle, group=self.group, meter_start=40, - meter_stop=49 + meter_stop=49, ) - self.form_data['meter_start'] = 45 + self.form_data["meter_start"] = 45 form = JournalEntryForm(self.form_data) self.assertFalse(form.is_valid()) - self.assertTrue('meter_start' in form.errors) + self.assertTrue("meter_start" in form.errors) - #TODO: fixme def test_too_small_meter_stop(self): """Test when meter stop is smaller than meter start.""" - self.form_data['meter_stop'] = 25 + self.form_data["meter_stop"] = 25 form = JournalEntryForm(self.form_data) self.assertFalse(form.is_valid()) - self.assertTrue('meter_stop' in form.errors) + self.assertTrue("meter_stop" in form.errors) - #TODO: fixme def test_valid_submission(self): """Test a valid submission.""" form = JournalEntryForm(self.form_data) @@ -99,7 +120,7 @@ def test_valid_submission(self): self.assertEqual(latest_entry.agreement, self.agreement) self.assertEqual(latest_entry.meter_start, 45) self.assertEqual(latest_entry.meter_stop, 49) - #TODO: fixme + def test_different_personnummer_format(self): """ Test a personnummer that has a different format. @@ -109,12 +130,13 @@ def test_different_personnummer_format(self): get the correct agreement. """ form_data = { - 'personnummer': '19980101-3039', - 'group': self.group.id, - 'meter_start': 45, - 'meter_stop': 49, - 'confirm': True, - 'g-recaptcha-response': 'PASSED' + "personnummer": "19980101-3039", + "group": self.group.id, + "vehicle": self.vehicle.id, + "meter_start": 45, + "meter_stop": 49, + "confirm": True, + "g-recaptcha-response": "PASSED", } form = JournalEntryForm(form_data) self.assertTrue(form.is_valid()) @@ -123,7 +145,50 @@ def test_different_personnummer_format(self): latest_entry = JournalEntry.get_latest_entry() self.assertEqual(latest_entry.agreement, self.agreement) - #TODO: fixme + def test_incorrect_vehicle_has_agreement_for_bikes(self): + """ + Test that a user which may only drive cars cannot submit a entry for bikes. + + A error should be added to to the form and it should be the only error available + in the form. + """ + vehicle = Vehicle.objects.exclude(id=self.vehicle.id).filter(car=False).get() + form_data = { + "personnummer": "19980101-3039", + "group": self.group.id, + "vehicle": vehicle.id, + "meter_start": 45, + "meter_stop": 49, + "confirm": True, + "g-recaptcha-response": "PASSED", + } + form = JournalEntryForm(form_data) + self.assertFalse(form.is_valid()) + all_errors = [(x, form.has_error(x)) for x in form.fields if form.has_error(x)] + self.assertTrue(len(all_errors) == 1) + self.assertTrue(all_errors[0][0] == "vehicle") + + def test_correct_vehicle_has_agreement_for_cars(self): + """ + Test that a user may drive vehicles of the same type, i.e. cars. + """ + vehicle = ( + Vehicle.objects.exclude(id=self.vehicle.id).filter(car=True).get() + ) # this picks a car which is not the initial vehicle type created within this test suite + form_data = { + "personnummer": "19980101-3039", + "group": self.group.id, + "vehicle": vehicle.id, + "meter_start": 45, + "meter_stop": 49, + "confirm": True, + "g-recaptcha-response": "PASSED", + } + form = JournalEntryForm(form_data) + self.assertTrue(form.is_valid()) + all_errors = [(x, form.has_error(x)) for x in form.fields if form.has_error(x)] + self.assertTrue(len(all_errors) == 0) + def test_expired_agreement(self): """ Test when a form is submitted with an expired agreement. @@ -134,12 +199,10 @@ def test_expired_agreement(self): self.agreement.expires = timezone.now().date() - timedelta(days=365) self.agreement.save() - response = self.client.post( - reverse('add-entry'), self.form_data, follow=True - ) - self.assertRedirects(response, reverse('add-entry-success')) + response = self.client.post(reverse("add-entry"), self.form_data, follow=True) + self.assertRedirects(response, reverse("add-entry-success")) - messages = list(response.context['messages']) + messages = list(response.context["messages"]) self.assertEqual(len(messages), 1) self.assertEqual(len(mail.outbox), 1) @@ -149,28 +212,24 @@ def test_expired_agreement(self): self.assertTrue(self.agreement.name in first_email.body) self.assertTrue(self.agreement.personnummer in first_email.body) - self.assertTrue( - settings.KLUBBMASTARE_EMAIL in first_email.recipients() - ) + self.assertTrue(settings.KLUBBMASTARE_EMAIL in first_email.recipients()) - #TODO: fixme def test_gap_notification(self): """Test that an email is sent if a gap occurs.""" # Create an earlier journal entry JournalEntry.objects.create( agreement=self.agreement, + vehicle=self.vehicle, group=self.group, meter_start=45, - meter_stop=49 + meter_stop=49, ) - self.form_data['meter_start'] = 60 - self.form_data['meter_stop'] = 70 - - response = self.client.post( - reverse('add-entry'), self.form_data, follow=True - ) - self.assertRedirects(response, reverse('add-entry-success')) + self.form_data["meter_start"] = 60 + self.form_data["meter_stop"] = 70 + self.form_data["vehicle"] = self.vehicle.id + response = self.client.post(reverse("add-entry"), self.form_data, follow=True) + self.assertRedirects(response, reverse("add-entry-success")) self.assertEqual(len(mail.outbox), 1) first_email = mail.outbox[0] @@ -178,24 +237,38 @@ def test_gap_notification(self): # Test that the name actually is in the email self.assertTrue(self.agreement.name in first_email.body) - self.assertTrue( - settings.KLUBBMASTARE_EMAIL in first_email.recipients() + self.assertTrue(settings.KLUBBMASTARE_EMAIL in first_email.recipients()) + + def test_no_gap_notification_different_vehicles(self): + """Test that an email is not sent if a gap occurs for different vehicles.""" + # Create an earlier journal entry + JournalEntry.objects.create( + agreement=self.agreement, + vehicle=self.vehicle, + group=self.group, + meter_start=45, + meter_stop=49, ) + vehicle = Vehicle.objects.exclude(id=self.vehicle.id).filter(car=True).get() + self.form_data["meter_start"] = 60 + self.form_data["meter_stop"] = 70 + self.form_data["vehicle"] = vehicle.id # comparing bocken with trockenkrocken + + response = self.client.post(reverse("add-entry"), self.form_data, follow=True) + self.assertEqual(len(mail.outbox), 0) - #TODO: fixme def test_t_number_wrong_format(self): """Test a submission with the wrong format on the T number.""" - self.form_data['personnummer'] = '19980101-T728' + self.form_data["personnummer"] = "19980101-T728" form = JournalEntryForm(self.form_data) self.assertFalse(form.is_valid()) - self.assertTrue('personnummer' in form.errors) - self.assertIn('YYYYMMDDXXXX', form.errors['personnummer'][0]) + self.assertTrue("personnummer" in form.errors) + self.assertIn("YYYYMMDDXXXX", form.errors["personnummer"][0]) - #TODO: fixme def test_t_number(self): """Test submitting with the correct format on T-number.""" - self.form_data['personnummer'] = '19980101T728' + self.form_data["personnummer"] = "19980101T728" form = JournalEntryForm(self.form_data) self.assertTrue(form.is_valid()) From 592f4c6f7cb458c93bf32c3bc2ad5aab041b08e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 10 Jul 2024 15:21:53 +0200 Subject: [PATCH 11/19] wip: missed a file :) --- src/bocken/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bocken/views.py b/src/bocken/views.py index e7d8b76..8913759 100644 --- a/src/bocken/views.py +++ b/src/bocken/views.py @@ -52,7 +52,8 @@ def form_valid(self, form): latest_entry = JournalEntry.get_latest_entry() if latest_entry: previous_meter_stop = latest_entry.meter_stop - if form.cleaned_data['meter_start'] > previous_meter_stop: + previous_vehicle = latest_entry.vehicle + if form.cleaned_data['meter_start'] > previous_meter_stop and previous_vehicle == form.cleaned_data['vehicle']: mail_admins( "A gap has occured", ( From b6b20422fb0fdd7d550b421d5fdd4c2d60017de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 10 Jul 2024 15:39:24 +0200 Subject: [PATCH 12/19] wip: typo --- src/bocken/admin/vehicle_admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bocken/admin/vehicle_admin.py b/src/bocken/admin/vehicle_admin.py index 3e356d6..b1e5c82 100644 --- a/src/bocken/admin/vehicle_admin.py +++ b/src/bocken/admin/vehicle_admin.py @@ -3,7 +3,7 @@ class VehicleAdmin(ModelAdmin): - """Custom class for the admin pages for JournalEntryGroup.""" + """Custom class for the admin pages for Vehicle.""" list_display = ("vehicle_name", "car", "bike", "vehicle_meter_start", "vehicle_meter_stop") list_filter = ['vehicle_name'] From 0fd556273aa0801578befe7131ea773269750168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 10 Jul 2024 15:55:11 +0200 Subject: [PATCH 13/19] wip: translations --- src/bocken/locale/sv/LC_MESSAGES/django.po | 219 +++++++++++++++------ 1 file changed, 156 insertions(+), 63 deletions(-) diff --git a/src/bocken/locale/sv/LC_MESSAGES/django.po b/src/bocken/locale/sv/LC_MESSAGES/django.po index 570a019..35ecf3a 100644 --- a/src/bocken/locale/sv/LC_MESSAGES/django.po +++ b/src/bocken/locale/sv/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-05-15 16:35+0200\n" +"POT-Creation-Date: 2024-07-10 15:25+0200\n" "PO-Revision-Date: 2022-12-14 15:56+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -18,11 +18,11 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.2.2\n" -#: bocken/admin/__init__.py:22 +#: bocken/admin/__init__.py:24 msgid "Bocken Administration" msgstr "Bocken Administration" -#: bocken/admin/__init__.py:23 +#: bocken/admin/__init__.py:25 msgid "Bocken Journal System" msgstr "Bockens Journalsystem" @@ -47,7 +47,7 @@ msgid "Change the user's password here." msgstr "Ändra användarens lösenord här." #: bocken/forms/agreement_expire_form.py:17 -#: bocken/forms/journal_entry_form.py:24 +#: bocken/forms/journal_entry_form.py:25 msgid "Your personnummer" msgstr "Ditt personnummer" @@ -60,19 +60,19 @@ msgstr "" msgid "Signed agreement" msgstr "Signerat köravtal" -#: bocken/forms/journal_entry_form.py:29 +#: bocken/forms/journal_entry_form.py:30 msgid "I confirm that Bocken is clean and in good shape" msgstr "Jag intygar att Bocken är i gott skick" -#: bocken/forms/journal_entry_form.py:43 +#: bocken/forms/journal_entry_form.py:44 msgid "Trip meter at start" msgstr "Mätare vid start" -#: bocken/forms/journal_entry_form.py:50 +#: bocken/forms/journal_entry_form.py:51 msgid "Trip meter at stop" msgstr "Mätare vid stop" -#: bocken/forms/journal_entry_form.py:63 +#: bocken/forms/journal_entry_form.py:65 msgid "" "Not sure which group to choose? Choose the group that seems most reasonable " "to be paying for your trip." @@ -80,7 +80,7 @@ msgstr "" "Osäker på vilken grupp du ska välja? Välj den grupp som känns mest rimlig " "att betala din resa." -#: bocken/forms/journal_entry_form.py:67 +#: bocken/forms/journal_entry_form.py:69 msgid "" "Trip meter at start is filled in automatically from the latest entry. If the " "number is not correct, enter the value that the meter had when you started " @@ -90,27 +90,46 @@ msgstr "" "inte stämmer, skriv in det värde som mätaren hade när du började köra samt " "informera UTN:s klubbmästare." -#: bocken/forms/journal_entry_form.py:110 +#: bocken/forms/journal_entry_form.py:75 +msgid "Choose the type of vehicle you have driven." +msgstr "Välj vilket fordon du har kört." + +#: bocken/forms/journal_entry_form.py:124 msgid "Trip meter at start must be larger than the last entry in the journal" msgstr "" "Tripmätare vid start måste vara större än det senaste inlägget i körjournalen" -#: bocken/forms/journal_entry_form.py:134 +#: bocken/forms/journal_entry_form.py:149 +#, fuzzy +#| msgid "" +#| "You don't have a written agreement which you must have to drive bocken. " +#| "Contact the head of the pub crew and send a copy of the details you wrote " +#| "into the fields below." +msgid "" +"You don't have a written agreement which you must have to drive a car. " +"Contact the head of the pub crew and send a copy of the details you wrote " +"into the fields below." +msgstr "" +"Du har inte ett skrivet avtal som du måsta ha för att köra bilar. Kontakta " +"UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " +"fälten nedan." + +#: bocken/forms/journal_entry_form.py:157 #, fuzzy #| msgid "" #| "You don't have a written agreement which you must have to drive bocken. " #| "Contact the head of the pub crew and send a copy of the details you wrote " #| "into the fields below." msgid "" -"You don't have the correct type of agreement to add an entry for bocken. " -"Contact the head of the pub crew with a new agreement to be able to drive " -"bocken." +"You don't have a written agreement which you must have to drive a bike. " +"Contact the head of the pub crew and send a copy of the details you wrote " +"into the fields below." msgstr "" -"Du har inte skrivit de avtal som du måsta ha för att köra bocken. Kontakta " +"Du har inte ett skrivet avtal som du måsta ha för att köra cyklar. Kontakta " "UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " "fälten nedan." -#: bocken/forms/journal_entry_form.py:143 +#: bocken/forms/journal_entry_form.py:166 msgid "" "You don't have a written agreement which you must have to drive bocken. " "Contact the head of the pub crew and send a copy of the details you wrote " @@ -120,7 +139,7 @@ msgstr "" "UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " "fälten nedan." -#: bocken/forms/journal_entry_form.py:153 +#: bocken/forms/journal_entry_form.py:177 msgid "Trip meter at stop must be larger than the trip meter at start" msgstr "Tripmätare vid stop måste vara större än trippmätare vid start" @@ -146,7 +165,7 @@ msgid "Admins" msgstr "Administratörer" #: bocken/models/agreement.py:44 bocken/models/journal_entry_group.py:10 -#: bocken/templates/journalentry_create.html:57 +#: bocken/models/vehicle.py:10 bocken/templates/journalentry_create.html:69 msgid "Name" msgstr "Namn" @@ -158,47 +177,53 @@ msgstr "För- och efternamn" msgid "Phonenumber" msgstr "Telefonnummer" -#: bocken/models/agreement.py:84 +#: bocken/models/agreement.py:83 #, fuzzy #| msgid "Agreement" -msgid "Agreement for Bocken" -msgstr "Avtal för Bocken" +msgid "Agreement for cars" +msgstr "Avtal för bilar" -#: bocken/models/agreement.py:86 -msgid "Designates whether user has a agreement which applies for Bocken or not." -msgstr ""Avgör ifall en användare har ett avtal som gäller Hornet eller ej. +#: bocken/models/agreement.py:85 +#, fuzzy +#| msgid "" +#| "Designates whether user has a agreement which applies for Bocken or not." +msgid "Designates whether user has a agreement which applies for cars or not." +msgstr "Avgör ifall en användare har ett avtal som gäller bilar eller ej." -#: bocken/models/agreement.py:90 +#: bocken/models/agreement.py:89 #, fuzzy #| msgid "Agreement" -msgid "Agreement for Hornet" -msgstr "Avtal för Hornet" +msgid "Agreement for bikes" +msgstr "Avtal för cyklar" -#: bocken/models/agreement.py:92 -msgid "Designates whether user has a agreement which applies for Hornet or not." -msgstr "Avgör ifall en användare har ett avtal som gäller Hornet eller ej." +#: bocken/models/agreement.py:91 +#, fuzzy +#| msgid "" +#| "Designates whether user has a agreement which applies for Bocken or not." +msgid "Designates whether user has a agreement which applies for bikes or not." +msgstr "Avgör ifall en användare har ett avtal som gäller cyklar eller ej." -#: bocken/models/agreement.py:96 +#: bocken/models/agreement.py:97 msgid "Valid until" msgstr "Giltig t.o.m" -#: bocken/models/agreement.py:98 +#: bocken/models/agreement.py:99 msgid "Agreements are valid for 1 year by default." msgstr "Avtal är giltiga i 1 år som standard." -#: bocken/models/agreement.py:102 bocken/models/journal_entry.py:20 +#: bocken/models/agreement.py:103 bocken/models/journal_entry.py:20 msgid "Agreement" msgstr "Köravtal" -#: bocken/models/agreement.py:103 +#: bocken/models/agreement.py:104 msgid "Agreements" msgstr "Köravtal" -#: bocken/models/agreement.py:142 +#: bocken/models/agreement.py:143 msgid "Reminder to update your Bocken agreement" msgstr "Påminnelse för att uppdatera ditt Bockenavtal" -#: bocken/models/agreement.py:144 +#: bocken/models/agreement.py:145 msgid "" "This is an automated message from UTN:s journal system for Bocken. You are " "receiving this email because you have a Bocken agreement that will expire in " @@ -216,34 +241,40 @@ msgstr "" #: bocken/models/journal_entry.py:25 #: bocken/templates/admin/change_report_form.html:10 -#: bocken/templates/journalentry_create.html:61 +#: bocken/templates/journalentry_create.html:77 msgid "Group" msgstr "Grupp" -#: bocken/models/journal_entry.py:28 -#: bocken/templates/journalentry_create.html:65 +#: bocken/models/journal_entry.py:30 bocken/models/vehicle.py:31 +#: bocken/templates/admin/change_report_form.html:12 +#: bocken/templates/journalentry_create.html:73 +msgid "Vehicle" +msgstr "" + +#: bocken/models/journal_entry.py:35 +#: bocken/templates/journalentry_create.html:81 msgid "Meter at start" msgstr "Mätare vid start" -#: bocken/models/journal_entry.py:31 -#: bocken/templates/journalentry_create.html:69 +#: bocken/models/journal_entry.py:38 +#: bocken/templates/journalentry_create.html:85 msgid "Meter at stop" msgstr "Mätare vid stop" -#: bocken/models/journal_entry.py:35 bocken/models/report.py:29 -#: bocken/templates/journalentry_create.html:73 +#: bocken/models/journal_entry.py:42 bocken/models/report.py:30 +#: bocken/templates/journalentry_create.html:89 msgid "Created" msgstr "Skapad" -#: bocken/models/journal_entry.py:39 +#: bocken/models/journal_entry.py:46 msgid "Journal Entry" msgstr "Journalinlägg" -#: bocken/models/journal_entry.py:40 +#: bocken/models/journal_entry.py:47 msgid "Journal Entries" msgstr "Journalinlägg" -#: bocken/models/journal_entry.py:57 +#: bocken/models/journal_entry.py:64 msgid "Driven Distance (km)" msgstr "Körd sträcka (km)" @@ -303,6 +334,30 @@ msgstr "Rapport" msgid "Reports" msgstr "Rapporter" +#: bocken/models/vehicle.py:14 +msgid "Is the vehicle a car" +msgstr "" + +#: bocken/models/vehicle.py:18 +msgid "Is the vehicle a bike" +msgstr "" + +#: bocken/models/vehicle.py:22 +#, fuzzy +#| msgid "Meter at start" +msgid "Meter at start (Latest Entry)" +msgstr "Mätare vid start (Senaste inlägg)" + +#: bocken/models/vehicle.py:26 +#, fuzzy +#| msgid "Meter at stop" +msgid "Meter at stop (Latest Entry)" +msgstr "Mätare vid stop (Senaste inlägg)" + +#: bocken/models/vehicle.py:32 +msgid "Vehicles" +msgstr "" + #: bocken/settings/base.py:100 msgid "Swedish" msgstr "Svenska" @@ -369,57 +424,72 @@ msgid "OBS! The starting fee is not included in the total cost!" msgstr "OBS! Startavgiften är inte inkluderad i den totala kostnaden!" #: bocken/templates/admin/change_report_form.html:11 +#, fuzzy +#| msgid "Main group" +msgid "Main Group" +msgstr "Huvudgrupp" + +#: bocken/templates/admin/change_report_form.html:13 #: bocken/templates/journalentry_create.html:16 msgid "Total kilometers" msgstr "Antal kilometer" -#: bocken/templates/admin/change_report_form.html:12 +#: bocken/templates/admin/change_report_form.html:14 msgid "Total mil" msgstr "Antal mil" -#: bocken/templates/admin/change_report_form.html:13 +#: bocken/templates/admin/change_report_form.html:15 msgid "Total cost" msgstr "Total kostnad" -#: bocken/templates/admin/change_report_form.html:28 +#: bocken/templates/admin/change_report_form.html:32 msgid "Total" msgstr "Totalt" -#: bocken/templates/admin/change_report_form.html:35 +#: bocken/templates/admin/change_report_form.html:39 +#, fuzzy +#| msgid "" +#| "Groups that are not displayed above have not driven bocken during this " +#| "time period" msgid "" -"Groups that are not displayed above have not driven bocken during this time " -"period" +"Groups that are not displayed above have not driven any vehicle during this " +"time period" msgstr "" "Grupper som inte visas ovan har inte kört bocken något under denna tidsperiod" -#: bocken/templates/admin/change_report_form.html:37 -#: bocken/templates/admin/change_report_form.html:62 +#: bocken/templates/admin/change_report_form.html:41 +#: bocken/templates/admin/change_report_form.html:66 msgid "Lost cost" msgstr "Förlorad kostnad" -#: bocken/templates/admin/change_report_form.html:39 +#: bocken/templates/admin/change_report_form.html:43 +#, fuzzy +#| msgid "" +#| "Lost costs appear when a group has driven bocken but not filled in the " +#| "journal afterwards. This means that it is not possible to know who should " +#| "pay for those kilometers and the cost is therefore \"lost\"." msgid "" -"Lost costs appear when a group has driven bocken but not filled in the " +"Lost costs appear when a group has driven a vehicle but not filled in the " "journal afterwards. This means that it is not possible to know who should " "pay for those kilometers and the cost is therefore \"lost\"." msgstr "" -"Förlorade kostnader uppstår när en grupp har kört bocken men glömt fylla i " +"Förlorade kostnader uppstår när en grupp har kört ett fordon men glömt fylla i " "körjournalen efteråt. Det betyder att det inte går att avgöra vem som ska " "betala för dem kilometrarna och den kostnaden är därmed \"förlorad\"." -#: bocken/templates/admin/change_report_form.html:44 +#: bocken/templates/admin/change_report_form.html:48 msgid "Total driven kilometers" msgstr "Totalt körda kilometer" -#: bocken/templates/admin/change_report_form.html:48 +#: bocken/templates/admin/change_report_form.html:52 msgid "Total logged kilometers" msgstr "Totalt journalförda kilometer" -#: bocken/templates/admin/change_report_form.html:53 +#: bocken/templates/admin/change_report_form.html:57 msgid "Difference" msgstr "Differens" -#: bocken/templates/admin/change_report_form.html:72 +#: bocken/templates/admin/change_report_form.html:76 msgid "Save cost per mil" msgstr "Spara kostnad per mil" @@ -470,7 +540,7 @@ msgstr "nya journalinlägg sen den senaste rapporten" msgid "The digital journalsystem for Bocken" msgstr "Den digitala körjournalen för Bocken" -#: bocken/templates/journalentry_create.html:45 +#: bocken/templates/journalentry_create.html:57 msgid "" "Is something not working? Contact the head of the pub crew and send a copy " "of the details you provided above to" @@ -478,7 +548,7 @@ msgstr "" "Strular formuläret? Kontakta UTN:s klubbmästare och skicka med en kopia av " "informationen som du skrev in ovanför till" -#: bocken/templates/journalentry_create.html:52 +#: bocken/templates/journalentry_create.html:64 msgid "Previous entries" msgstr "Senaste inläggen" @@ -606,3 +676,26 @@ msgstr "" "Ditt avtal har gått ut! Journalinlägget du nyss skapade har sparats men du " "måste förnya ditt avtal. Kontakta UTN:s klubbmästare: %(email)s" + +#, fuzzy +#~| msgid "" +#~| "You don't have a written agreement which you must have to drive bocken. " +#~| "Contact the head of the pub crew and send a copy of the details you " +#~| "wrote into the fields below." +#~ msgid "" +#~ "You don't have the correct type of agreement to add an entry for bocken. " +#~ "Contact the head of the pub crew with a new agreement to be able to drive " +#~ "bocken." +#~ msgstr "" +#~ "Du har inte skrivit de avtal som du måsta ha för att köra bocken. " +#~ "Kontakta UTN:s klubbmästare och skicka med en kopia av detaljerna som du " +#~ "skrev in i fälten nedan." + +#, fuzzy +#~| msgid "Agreement" +#~ msgid "Agreement for Hornet" +#~ msgstr "Avtal för Hornet" + +#~ msgid "" +#~ "Designates whether user has a agreement which applies for Hornet or not." +#~ msgstr "Avgör ifall en användare har ett avtal som gäller Hornet eller ej." From 198cae9d36463f0172910b2e66f46ed2849fbaac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Thu, 11 Jul 2024 14:57:02 +0200 Subject: [PATCH 14/19] wip: translations --- src/bocken/forms/journal_entry_form.py | 2 +- src/bocken/locale/sv/LC_MESSAGES/django.po | 83 +++++----------------- 2 files changed, 20 insertions(+), 65 deletions(-) diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index 1f4f3e7..10ff784 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -27,7 +27,7 @@ class JournalEntryForm(ModelForm): confirm = BooleanField( required=True, - label=_("I confirm that Bocken is clean and in good shape"), + label=_("I confirm that the vehicle is clean and in good shape"), widget=CheckboxInput(attrs={'class': 'h-8 w-8'}) ) diff --git a/src/bocken/locale/sv/LC_MESSAGES/django.po b/src/bocken/locale/sv/LC_MESSAGES/django.po index 35ecf3a..1b321e1 100644 --- a/src/bocken/locale/sv/LC_MESSAGES/django.po +++ b/src/bocken/locale/sv/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 15:25+0200\n" +"POT-Creation-Date: 2024-07-11 14:25+0200\n" "PO-Revision-Date: 2022-12-14 15:56+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -61,8 +61,10 @@ msgid "Signed agreement" msgstr "Signerat köravtal" #: bocken/forms/journal_entry_form.py:30 -msgid "I confirm that Bocken is clean and in good shape" -msgstr "Jag intygar att Bocken är i gott skick" +#, fuzzy +#| msgid "I confirm that Bocken is clean and in good shape" +msgid "I confirm that the vehicle is clean and in good shape" +msgstr "Jag intygar att fordonet är i gott skick" #: bocken/forms/journal_entry_form.py:44 msgid "Trip meter at start" @@ -110,7 +112,7 @@ msgid "" "Contact the head of the pub crew and send a copy of the details you wrote " "into the fields below." msgstr "" -"Du har inte ett skrivet avtal som du måsta ha för att köra bilar. Kontakta " +"Du har inte de skrivna avtalet som du måsta ha för att köra bilar. Kontakta " "UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " "fälten nedan." @@ -125,7 +127,7 @@ msgid "" "Contact the head of the pub crew and send a copy of the details you wrote " "into the fields below." msgstr "" -"Du har inte ett skrivet avtal som du måsta ha för att köra cyklar. Kontakta " +"Du har inte de skrivna avtalet som du måsta ha för att köra cyklar. Kontakta " "UTN:s klubbmästare och skicka med en kopia av detaljerna som du skrev in i " "fälten nedan." @@ -178,8 +180,6 @@ msgid "Phonenumber" msgstr "Telefonnummer" #: bocken/models/agreement.py:83 -#, fuzzy -#| msgid "Agreement" msgid "Agreement for cars" msgstr "Avtal för bilar" @@ -191,8 +191,6 @@ msgid "Designates whether user has a agreement which applies for cars or not." msgstr "Avgör ifall en användare har ett avtal som gäller bilar eller ej." #: bocken/models/agreement.py:89 -#, fuzzy -#| msgid "Agreement" msgid "Agreement for bikes" msgstr "Avtal för cyklar" @@ -249,7 +247,7 @@ msgstr "Grupp" #: bocken/templates/admin/change_report_form.html:12 #: bocken/templates/journalentry_create.html:73 msgid "Vehicle" -msgstr "" +msgstr "Fordon" #: bocken/models/journal_entry.py:35 #: bocken/templates/journalentry_create.html:81 @@ -319,10 +317,10 @@ msgid "Cost per mil (kr)" msgstr "Kostnad per mil (kr)" #: bocken/models/report.py:36 -msgid "" +msgid "Each report can have a different cost per mil. This allows the cost per mil " "to be changed without affecting previous reports" -msgstr "" +msgstr "Alla rapporter kan ha olika kostnader per mil. Detta gör det möjligt att " "ändra kostnaden per mil utan att påverka tidigare rapporter" @@ -336,27 +334,23 @@ msgstr "Rapporter" #: bocken/models/vehicle.py:14 msgid "Is the vehicle a car" -msgstr "" +msgstr "Är fordonet en bil" #: bocken/models/vehicle.py:18 msgid "Is the vehicle a bike" -msgstr "" +msgstr "Är fordonet en cykel" #: bocken/models/vehicle.py:22 -#, fuzzy -#| msgid "Meter at start" msgid "Meter at start (Latest Entry)" msgstr "Mätare vid start (Senaste inlägg)" #: bocken/models/vehicle.py:26 -#, fuzzy -#| msgid "Meter at stop" msgid "Meter at stop (Latest Entry)" msgstr "Mätare vid stop (Senaste inlägg)" #: bocken/models/vehicle.py:32 msgid "Vehicles" -msgstr "" +msgstr "Fordon" #: bocken/settings/base.py:100 msgid "Swedish" @@ -382,8 +376,7 @@ msgstr "Hem" #: bocken/templates/admin/add_report_form.html:34 msgid "A report can not be created since there are no journal entries" -msgstr "" -"En rapport kan inte skapas eftersom att det inte finns några journalinlägg" +msgstr "En rapport kan inte skapas eftersom att det inte finns några journalinlägg" #: bocken/templates/admin/add_report_form.html:36 msgid "" @@ -424,8 +417,6 @@ msgid "OBS! The starting fee is not included in the total cost!" msgstr "OBS! Startavgiften är inte inkluderad i den totala kostnaden!" #: bocken/templates/admin/change_report_form.html:11 -#, fuzzy -#| msgid "Main group" msgid "Main Group" msgstr "Huvudgrupp" @@ -447,15 +438,11 @@ msgid "Total" msgstr "Totalt" #: bocken/templates/admin/change_report_form.html:39 -#, fuzzy -#| msgid "" -#| "Groups that are not displayed above have not driven bocken during this " -#| "time period" msgid "" "Groups that are not displayed above have not driven any vehicle during this " "time period" msgstr "" -"Grupper som inte visas ovan har inte kört bocken något under denna tidsperiod" +"Grupper som inte visas ovan har inte kört något fordon något under denna tidsperiod" #: bocken/templates/admin/change_report_form.html:41 #: bocken/templates/admin/change_report_form.html:66 @@ -463,18 +450,13 @@ msgid "Lost cost" msgstr "Förlorad kostnad" #: bocken/templates/admin/change_report_form.html:43 -#, fuzzy -#| msgid "" -#| "Lost costs appear when a group has driven bocken but not filled in the " -#| "journal afterwards. This means that it is not possible to know who should " -#| "pay for those kilometers and the cost is therefore \"lost\"." msgid "" "Lost costs appear when a group has driven a vehicle but not filled in the " "journal afterwards. This means that it is not possible to know who should " "pay for those kilometers and the cost is therefore \"lost\"." msgstr "" -"Förlorade kostnader uppstår när en grupp har kört ett fordon men glömt fylla i " -"körjournalen efteråt. Det betyder att det inte går att avgöra vem som ska " +"Förlorade kostnader uppstår när en grupp har kört ett fordon men glömt fylla " +"i körjournalen efteråt. Det betyder att det inte går att avgöra vem som ska " "betala för dem kilometrarna och den kostnaden är därmed \"förlorad\"." #: bocken/templates/admin/change_report_form.html:48 @@ -573,10 +555,8 @@ msgid "Sign an agreement" msgstr "Skriv under ett köravtal" #: bocken/templates/start_page.html:12 -msgid "" -"In order to drive Bocken you need to sign an agreement. Follow these steps:" -msgstr "" -"För att få köra Bocken måste du har ett signerat köravtal. Följ dessa steg:" +msgid "In order to drive Bocken you need to sign an agreement. Follow these steps:" +msgstr "För att få köra Bocken måste du har ett signerat köravtal. Följ dessa steg:" #: bocken/templates/start_page.html:15 msgid "Download" @@ -661,8 +641,6 @@ msgid "Invalid phonenumber" msgstr "Ogiltigt telefonnummer" #: bocken/validators.py:59 -#, fuzzy -#| msgid "Your personnummer must be on the format YYYYMMDD-XXXX" msgid "Your personnummer must be on the format YYYYMMDDXXXX" msgstr "Ditt personnummer måste vara på formen YYYYMMDD-XXXX" @@ -676,26 +654,3 @@ msgstr "" "Ditt avtal har gått ut! Journalinlägget du nyss skapade har sparats men du " "måste förnya ditt avtal. Kontakta UTN:s klubbmästare: %(email)s" - -#, fuzzy -#~| msgid "" -#~| "You don't have a written agreement which you must have to drive bocken. " -#~| "Contact the head of the pub crew and send a copy of the details you " -#~| "wrote into the fields below." -#~ msgid "" -#~ "You don't have the correct type of agreement to add an entry for bocken. " -#~ "Contact the head of the pub crew with a new agreement to be able to drive " -#~ "bocken." -#~ msgstr "" -#~ "Du har inte skrivit de avtal som du måsta ha för att köra bocken. " -#~ "Kontakta UTN:s klubbmästare och skicka med en kopia av detaljerna som du " -#~ "skrev in i fälten nedan." - -#, fuzzy -#~| msgid "Agreement" -#~ msgid "Agreement for Hornet" -#~ msgstr "Avtal för Hornet" - -#~ msgid "" -#~ "Designates whether user has a agreement which applies for Hornet or not." -#~ msgstr "Avgör ifall en användare har ett avtal som gäller Hornet eller ej." From 05e0730b513bccc0cb1543f8d8ebb20c8cdf0395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Thu, 11 Jul 2024 15:09:28 +0200 Subject: [PATCH 15/19] changed form autofill logic for vehicles Now fetches the attribute based on the vehicle id which is stored in the DOM, technically has support for unlimited vehicles but might not as fool proof. --- src/bocken/forms/journal_entry_form.py | 2 +- src/bocken/templates/journalentry_create.html | 14 ++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index 10ff784..c9cd3c4 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -93,7 +93,7 @@ def __init__(self, *args, **kwargs): # their latest trip to automatically set as a value for when # a user is registering a new journal entry for item in latest_entries: - self.initial[f'meter_start_{str(item.vehicle).lower()}'] = item.meter_stop + self.initial[f'meter_start_{str(item.vehicle.id).lower()}'] = item.meter_stop # If there is data from the previous form (a.k.a. invalid data # was passed) we need to add some of that data to the TwoLevelSelect diff --git a/src/bocken/templates/journalentry_create.html b/src/bocken/templates/journalentry_create.html index 410dfd3..9306783 100644 --- a/src/bocken/templates/journalentry_create.html +++ b/src/bocken/templates/journalentry_create.html @@ -120,18 +120,8 @@

{% translate 'Previous entries' %}

const v_id = document.getElementById("id_vehicle").value; const meter_start_container_elem = document.getElementById("id_meter_start_container"); const meter_start_elem = document.getElementById("id_meter_start"); - switch(v_id) { - case "2": //bike - const bike_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_bjällran")); - meter_start_elem.value = bike_meter; - break; - case "1": //car - const car_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_bocken")); - meter_start_elem.value = car_meter; - break; - default: - console.log("Couldn't identify the type of vehicle choose, silently failing :(") - } + const vehicle_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_"+v_id)); + meter_start_elem.value = vehicle_meter } {% endblock %} From 7b16fc77fec94e320801e6fe3da267335cc18bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Thu, 11 Jul 2024 15:16:29 +0200 Subject: [PATCH 16/19] add base to parseInt + docs --- src/bocken/templates/journalentry_create.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bocken/templates/journalentry_create.html b/src/bocken/templates/journalentry_create.html index 9306783..9c93736 100644 --- a/src/bocken/templates/journalentry_create.html +++ b/src/bocken/templates/journalentry_create.html @@ -101,8 +101,8 @@

{% translate 'Previous entries' %}

document.getElementById("id_meter_stop").addEventListener("input", calculateTotalDistance); document.addEventListener("DOMContentLoaded", calculateTotalDistance); function calculateTotalDistance(){ - const start = parseInt(document.getElementById("id_meter_start").value); - const stop = parseInt(document.getElementById("id_meter_stop").value); + const start = parseInt(document.getElementById("id_meter_start").value,10); + const stop = parseInt(document.getElementById("id_meter_stop").value,10); let value = 0; // Only show the distance driven if the stop is larger than the start. @@ -117,10 +117,13 @@

{% translate 'Previous entries' %}

} document.getElementById("id_vehicle").addEventListener("change", getSpecificVehicleValue); function getSpecificVehicleValue(){ + // fetch primary key based on selection in option-element const v_id = document.getElementById("id_vehicle").value; + + // update meter_start with v_id via reading the attribute for that particular vehicle const meter_start_container_elem = document.getElementById("id_meter_start_container"); const meter_start_elem = document.getElementById("id_meter_start"); - const vehicle_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_"+v_id)); + const vehicle_meter = parseInt(meter_start_container_elem.getAttribute("meter_start_"+v_id),10); meter_start_elem.value = vehicle_meter } From 2ae2ea067bdb9c991c54c3c2ba709cda7a7205fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Wed, 25 Sep 2024 17:07:48 +0200 Subject: [PATCH 17/19] fix: init meter with no entries If there were no entries the code didn't fetch the meter stats from the vehicle objects, now it does :) --- src/bocken/forms/journal_entry_form.py | 10 ++++++++-- src/bocken/templates/journalentry_create.html | 4 ---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index c9cd3c4..1bf3bed 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -93,8 +93,14 @@ def __init__(self, *args, **kwargs): # their latest trip to automatically set as a value for when # a user is registering a new journal entry for item in latest_entries: - self.initial[f'meter_start_{str(item.vehicle.id).lower()}'] = item.meter_stop - + print(f"{str(item.vehicle.id)}") + print(f"{str(item.vehicle.id).lower()}") + self.initial[f'meter_start_{str(item.vehicle.id)}'] = item.meter_stop + else: + #if there is not a latest entry, then fetch from vehicle objects + #ideally it should always be fetched from here but..? + for vehicle in all_vehicles: + self.initial[f'meter_start_{str(vehicle.id)}'] = vehicle.vehicle_meter_stop # If there is data from the previous form (a.k.a. invalid data # was passed) we need to add some of that data to the TwoLevelSelect # widget so that it can automatically choose a default option. diff --git a/src/bocken/templates/journalentry_create.html b/src/bocken/templates/journalentry_create.html index 9c93736..3bb909f 100644 --- a/src/bocken/templates/journalentry_create.html +++ b/src/bocken/templates/journalentry_create.html @@ -25,10 +25,6 @@ {{field}} {% elif field == form.meter_start %}
Date: Thu, 26 Sep 2024 15:53:24 +0200 Subject: [PATCH 18/19] fix: add listener for meter start on dom load --- src/bocken/templates/journalentry_create.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bocken/templates/journalentry_create.html b/src/bocken/templates/journalentry_create.html index 3bb909f..c874880 100644 --- a/src/bocken/templates/journalentry_create.html +++ b/src/bocken/templates/journalentry_create.html @@ -112,6 +112,7 @@

{% translate 'Previous entries' %}

document.querySelector("#nr-kilometers").innerHTML = value; } document.getElementById("id_vehicle").addEventListener("change", getSpecificVehicleValue); + document.addEventListener("DOMContentLoaded", getSpecificVehicleValue); function getSpecificVehicleValue(){ // fetch primary key based on selection in option-element const v_id = document.getElementById("id_vehicle").value; From 35d31ff020eafb5415a613e454429a94dff132ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gr=C3=B6nlund?= Date: Tue, 15 Oct 2024 16:42:43 +0200 Subject: [PATCH 19/19] fix: lint :smile_cat: --- src/bocken/admin/vehicle_admin.py | 8 ++- src/bocken/forms/journal_entry_form.py | 57 ++++++++++-------- src/bocken/models/__init__.py | 3 +- src/bocken/models/agreement.py | 12 ++-- src/bocken/models/journal_entry.py | 16 ++--- src/bocken/models/report.py | 30 ++++++---- src/bocken/tests/test_journal_entry_form.py | 65 ++++++++++++++------- src/bocken/views.py | 4 +- 8 files changed, 126 insertions(+), 69 deletions(-) diff --git a/src/bocken/admin/vehicle_admin.py b/src/bocken/admin/vehicle_admin.py index b1e5c82..1580a35 100644 --- a/src/bocken/admin/vehicle_admin.py +++ b/src/bocken/admin/vehicle_admin.py @@ -5,5 +5,11 @@ class VehicleAdmin(ModelAdmin): """Custom class for the admin pages for Vehicle.""" - list_display = ("vehicle_name", "car", "bike", "vehicle_meter_start", "vehicle_meter_stop") + list_display = ( + "vehicle_name", + "car", + "bike", + "vehicle_meter_start", + "vehicle_meter_stop" + ) list_filter = ['vehicle_name'] diff --git a/src/bocken/forms/journal_entry_form.py b/src/bocken/forms/journal_entry_form.py index 1bf3bed..3cbc92d 100644 --- a/src/bocken/forms/journal_entry_form.py +++ b/src/bocken/forms/journal_entry_form.py @@ -82,25 +82,31 @@ def __init__(self, *args, **kwargs): # last entry based on the current vehicle choice since it most # likely is the value of the meter when a person starts driving. all_vehicles = Vehicle.objects.all() - latest_entries = [JournalEntry.get_latest_entry(x) for x in all_vehicles if JournalEntry.get_latest_entry(x) != None] + latest_entries = [ + JournalEntry.get_latest_entry(x) + for x in all_vehicles + if JournalEntry.get_latest_entry(x) is not None + ] if latest_entries: latest_entry = latest_entries[0] self.initial = { 'meter_start': latest_entry.meter_stop, } - # This stores all of the latest registered trips for each available vehicle. - # By doing this we can hence "support" any amount of vehicle and fetch - # their latest trip to automatically set as a value for when - # a user is registering a new journal entry + # This stores all of the latest registered trips for vehicle. + # By doing this we can hence "support" any amount of vehicle + # and fetch their latest trip to automatically set as + # a value for when a user is registering a new journal entry for item in latest_entries: - print(f"{str(item.vehicle.id)}") - print(f"{str(item.vehicle.id).lower()}") - self.initial[f'meter_start_{str(item.vehicle.id)}'] = item.meter_stop + self.initial[ + f'meter_start_{str(item.vehicle.id)}' + ] = item.meter_stop else: - #if there is not a latest entry, then fetch from vehicle objects - #ideally it should always be fetched from here but..? + # if there is not a latest entry, then fetch from vehicle objects + # ideally it should always be fetched from here but..? for vehicle in all_vehicles: - self.initial[f'meter_start_{str(vehicle.id)}'] = vehicle.vehicle_meter_stop + self.initial[ + f'meter_start_{str(vehicle.id)}' + ] = vehicle.vehicle_meter_stop # If there is data from the previous form (a.k.a. invalid data # was passed) we need to add some of that data to the TwoLevelSelect # widget so that it can automatically choose a default option. @@ -152,27 +158,27 @@ def clean(self): # noqa if veh.car: if not can_use_car: self.add_error('vehicle', _( - "You don't have a written agreement which you must have " - "to drive a car. Contact the head of the pub crew and " - "send a copy of the details you wrote into the fields " - "below." + "You don't have a written agreement which you " + "must have to drive a car. Contact the head of " + "the pub crew and send a copy of the details " + "you wrote inte the fields below." )) else: if not can_use_bike: self.add_error('vehicle', _( - "You don't have a written agreement which you must have " - "to drive a bike. Contact the head of the pub crew and " - "send a copy of the details you wrote into the fields " - "below." + "You don't have a written agreement which you " + "must have to drive a bike. Contact the head of " + "the pub crew and send a copy of the details " + "you wrote inte the fields below." )) self.instance.agreement = agreement except Agreement.DoesNotExist: self.add_error('personnummer', _( - "You don't have a written agreement which you must have " - "to drive bocken. Contact the head of the pub crew and " - "send a copy of the details you wrote into the fields " - "below." + "You don't have a written agreement which you " + "must have to drive a vehicle. Contact the head of " + "the pub crew and send a copy of the details " + "you wrote inte the fields below." )) # Make sure meter stop is larger than meter start @@ -185,5 +191,8 @@ def clean(self): # noqa )) else: if veh: - Vehicle.objects.filter(id=veh.id).update(vehicle_meter_start = meter_start, vehicle_meter_stop = meter_stop) + Vehicle.objects.filter(id=veh.id).update( + vehicle_meter_start=meter_start, + vehicle_meter_stop=meter_stop + ) return cleaned_data diff --git a/src/bocken/models/__init__.py b/src/bocken/models/__init__.py index 33dc62c..6100170 100644 --- a/src/bocken/models/__init__.py +++ b/src/bocken/models/__init__.py @@ -6,5 +6,6 @@ from .vehicle import Vehicle __all__ = [ - 'Admin', 'Agreement', 'JournalEntryGroup', 'Report', 'JournalEntry', 'Vehicle' + 'Admin', 'Agreement', 'JournalEntryGroup', + 'Report', 'JournalEntry', 'Vehicle' ] diff --git a/src/bocken/models/agreement.py b/src/bocken/models/agreement.py index 325deab..09ebc9e 100644 --- a/src/bocken/models/agreement.py +++ b/src/bocken/models/agreement.py @@ -82,17 +82,21 @@ class Agreement(models.Model): car_agreement = models.BooleanField( verbose_name=_("Agreement for cars"), default=False, - help_text=_("Designates whether user has a agreement which applies for cars or not.") + help_text=_( + """Designates whether user has a agreement + which applies for cars or not.""" + ) ) bike_agreement = models.BooleanField( verbose_name=_("Agreement for bikes"), default=False, - help_text=_("Designates whether user has a agreement which applies for bikes or not.") + help_text=_( + """Designates whether user has a agreement + which applies for bikes or not.""" + ) ) - - expires = models.DateField( verbose_name=_("Valid until"), default=get_default_expires, diff --git a/src/bocken/models/journal_entry.py b/src/bocken/models/journal_entry.py index e40ab46..c445261 100644 --- a/src/bocken/models/journal_entry.py +++ b/src/bocken/models/journal_entry.py @@ -108,21 +108,23 @@ def entries_exists(): return JournalEntry.objects.exists() @staticmethod - def get_latest_entry(vehicle_type = None): - """ - Get the entry that was last created based on the vehicle, - if no vehicle is supplied the latest trip in general is fetched. + def get_latest_entry(vehicle_type=None): + """Get the entry that was last created. + + If no vehicle_type is supplied the latest trip in general is fetched. """ try: if vehicle_type: - return JournalEntry.objects.filter(vehicle=vehicle_type).latest() + return JournalEntry.objects.filter( + vehicle=vehicle_type + ).latest() else: return JournalEntry.objects.latest() except JournalEntry.DoesNotExist: return None @staticmethod - def get_entries_between(start, end, vehicle_type = None): + def get_entries_between(start, end, vehicle_type=None): """ Get all entries between the timestamps start and end. @@ -132,7 +134,7 @@ def get_entries_between(start, end, vehicle_type = None): if vehicle_type: entries = JournalEntry.objects.filter( created__range=(start, end), - vehicle = vehicle_type + vehicle=vehicle_type ) else: entries = JournalEntry.objects.filter( diff --git a/src/bocken/models/report.py b/src/bocken/models/report.py index bc4b5d8..5c5d496 100644 --- a/src/bocken/models/report.py +++ b/src/bocken/models/report.py @@ -27,7 +27,8 @@ class Report(models.Model): last = models.DateTimeField() - created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) + created = models.DateTimeField(auto_now_add=True, + verbose_name=_("Created")) cost_per_mil = models.PositiveIntegerField( default=settings.COST_PER_MIL_DEFAULT, @@ -55,7 +56,9 @@ def get_entries(self): Returns a queryset of journal entries """ - return journal_entry.JournalEntry.get_entries_between(self.first, self.last) + return journal_entry.JournalEntry.get_entries_between( + self.first, self.last + ) def get_total_kilometers_driven(self): """Return the total kilometers that have been driven in this report.""" @@ -79,7 +82,8 @@ def get_statistics_for_groups(self): Returns list of dicts where each dict has the following structure { 'group': JournalEntryGroup instance - 'main_group', Display Name of main_group based on JournalEntryGroup instance + 'main_group', Display Name of main_group based + on JournalEntryGroup instance 'kilometers': Total kilometers (int), 'mil': Total mil (int), 'cost': Total cost (int) @@ -93,11 +97,11 @@ def get_statistics_for_groups(self): # What it does is that it calculates the total distance driven for each # journal entry and then sums them up for each group, giving us the # total kilometers for each group. - #TODO: Test this without vehicle-values + # TODO: Test this without vehicle-values kilometers_for_groups = ( - entries.values("group","vehicle") + entries.values("group", "vehicle") .annotate(total_kilometers=Sum("meter_stop") - Sum("meter_start")) - .order_by("group__main_group","group__name") + .order_by("group__main_group", "group__name") ) statistics = [] @@ -136,8 +140,12 @@ def get_total_statistics(self): total_kilometers = sum( statistic["kilometers"] for statistic in statistics_for_groups ) - total_mil = sum(statistic["mil"] for statistic in statistics_for_groups) - total_cost = sum(statistic["cost"] for statistic in statistics_for_groups) + total_mil = sum( + statistic["mil"] for statistic in statistics_for_groups + ) + total_cost = sum( + statistic["cost"] for statistic in statistics_for_groups + ) return { "total_kilometers": total_kilometers, @@ -232,9 +240,11 @@ def delete_old_reports(): Also deletes all journal entries in those reports. """ - one_and_half_years_ago = timezone.now() - relativedelta(years=1, months=6) + one_and_half_year_ago = ( + timezone.now() - relativedelta(years=1, months=6) + ) reports_to_delete = Report.objects.filter( - created__date__lte=one_and_half_years_ago + created__date__lte=one_and_half_year_ago ) # Delete all related journal entries diff --git a/src/bocken/tests/test_journal_entry_form.py b/src/bocken/tests/test_journal_entry_form.py index a7937e3..01eb246 100644 --- a/src/bocken/tests/test_journal_entry_form.py +++ b/src/bocken/tests/test_journal_entry_form.py @@ -50,11 +50,15 @@ def setUp(self): # noqa name="Gruppen", main_group="lg_and_board", ) - self.vehicle = Vehicle.objects.create(vehicle_name="TockenKrocken", car=True) + self.vehicle = Vehicle.objects.create( + vehicle_name="TockenKrocken", car=True + ) self.form_data = { "personnummer": "980101-3039", "group": self.group.id, - "vehicle": self.vehicle.id, # This id correlates to bocken as should be the default for all current journal entries + "vehicle": self.vehicle.id, + # This id correlates to bocken as should be the + # default for all current journal entries "meter_start": 45, "meter_stop": 49, "confirm": True, @@ -146,13 +150,14 @@ def test_different_personnummer_format(self): self.assertEqual(latest_entry.agreement, self.agreement) def test_incorrect_vehicle_has_agreement_for_bikes(self): - """ - Test that a user which may only drive cars cannot submit a entry for bikes. + """Test that a car only user submitting bike entry. - A error should be added to to the form and it should be the only error available - in the form. + A error should be added to to the form and it should + be the only error available in the form. """ - vehicle = Vehicle.objects.exclude(id=self.vehicle.id).filter(car=False).get() + vehicle = Vehicle.objects.exclude( + id=self.vehicle.id + ).filter(car=False).get() form_data = { "personnummer": "19980101-3039", "group": self.group.id, @@ -164,17 +169,19 @@ def test_incorrect_vehicle_has_agreement_for_bikes(self): } form = JournalEntryForm(form_data) self.assertFalse(form.is_valid()) - all_errors = [(x, form.has_error(x)) for x in form.fields if form.has_error(x)] + all_errors = [ + (x, form.has_error(x)) for x in form.fields if form.has_error(x) + ] self.assertTrue(len(all_errors) == 1) self.assertTrue(all_errors[0][0] == "vehicle") def test_correct_vehicle_has_agreement_for_cars(self): - """ - Test that a user may drive vehicles of the same type, i.e. cars. - """ + """Test that a user may drive vehicles of the same type, i.e. cars.""" vehicle = ( Vehicle.objects.exclude(id=self.vehicle.id).filter(car=True).get() - ) # this picks a car which is not the initial vehicle type created within this test suite + ) + # this picks a car which is not the + # initial vehicle type created within this test suite form_data = { "personnummer": "19980101-3039", "group": self.group.id, @@ -186,7 +193,9 @@ def test_correct_vehicle_has_agreement_for_cars(self): } form = JournalEntryForm(form_data) self.assertTrue(form.is_valid()) - all_errors = [(x, form.has_error(x)) for x in form.fields if form.has_error(x)] + all_errors = [ + (x, form.has_error(x)) for x in form.fields if form.has_error(x) + ] self.assertTrue(len(all_errors) == 0) def test_expired_agreement(self): @@ -199,7 +208,9 @@ def test_expired_agreement(self): self.agreement.expires = timezone.now().date() - timedelta(days=365) self.agreement.save() - response = self.client.post(reverse("add-entry"), self.form_data, follow=True) + response = self.client.post( + reverse("add-entry"), self.form_data, follow=True + ) self.assertRedirects(response, reverse("add-entry-success")) messages = list(response.context["messages"]) @@ -212,7 +223,9 @@ def test_expired_agreement(self): self.assertTrue(self.agreement.name in first_email.body) self.assertTrue(self.agreement.personnummer in first_email.body) - self.assertTrue(settings.KLUBBMASTARE_EMAIL in first_email.recipients()) + self.assertTrue( + settings.KLUBBMASTARE_EMAIL in first_email.recipients() + ) def test_gap_notification(self): """Test that an email is sent if a gap occurs.""" @@ -228,7 +241,9 @@ def test_gap_notification(self): self.form_data["meter_start"] = 60 self.form_data["meter_stop"] = 70 self.form_data["vehicle"] = self.vehicle.id - response = self.client.post(reverse("add-entry"), self.form_data, follow=True) + response = self.client.post( + reverse("add-entry"), self.form_data, follow=True + ) self.assertRedirects(response, reverse("add-entry-success")) self.assertEqual(len(mail.outbox), 1) @@ -237,10 +252,12 @@ def test_gap_notification(self): # Test that the name actually is in the email self.assertTrue(self.agreement.name in first_email.body) - self.assertTrue(settings.KLUBBMASTARE_EMAIL in first_email.recipients()) + self.assertTrue( + settings.KLUBBMASTARE_EMAIL in first_email.recipients() + ) def test_no_gap_notification_different_vehicles(self): - """Test that an email is not sent if a gap occurs for different vehicles.""" + """Test no email if a gap occurs for different vehicles.""" # Create an earlier journal entry JournalEntry.objects.create( agreement=self.agreement, @@ -249,12 +266,18 @@ def test_no_gap_notification_different_vehicles(self): meter_start=45, meter_stop=49, ) - vehicle = Vehicle.objects.exclude(id=self.vehicle.id).filter(car=True).get() + vehicle = Vehicle.objects.exclude( + id=self.vehicle.id + ).filter(car=True).get() self.form_data["meter_start"] = 60 self.form_data["meter_stop"] = 70 - self.form_data["vehicle"] = vehicle.id # comparing bocken with trockenkrocken + # comparing bocken with trockenkrocken + self.form_data["vehicle"] = vehicle.id - response = self.client.post(reverse("add-entry"), self.form_data, follow=True) + response = self.client.post( + reverse("add-entry"), self.form_data, follow=True + ) + self.assertRedirects(response, reverse("add-entry-success")) self.assertEqual(len(mail.outbox), 0) def test_t_number_wrong_format(self): diff --git a/src/bocken/views.py b/src/bocken/views.py index 8913759..864b762 100644 --- a/src/bocken/views.py +++ b/src/bocken/views.py @@ -53,7 +53,9 @@ def form_valid(self, form): if latest_entry: previous_meter_stop = latest_entry.meter_stop previous_vehicle = latest_entry.vehicle - if form.cleaned_data['meter_start'] > previous_meter_stop and previous_vehicle == form.cleaned_data['vehicle']: + c_stop = form.cleaned_data['meter_start'] + c_veh = form.cleaned_data['vehicle'] + if c_stop > previous_meter_stop and previous_vehicle == c_veh: mail_admins( "A gap has occured", (