Compare commits

..

9 Commits

Author SHA1 Message Date
Sebastian Lohff f4144633d6 Add link to sourcecode to footer 2022-01-22 19:28:12 +01:00
Sebastian Lohff 1f93f9e7bd Dynamically find currently active Contest
Previously we had a lot of hardcoded contest information in the
templates. Name, ruleset and number of contest are now all taken from
the currently active contest and rendered into the templates. Instead of
an URL containing the current contest name, we just use a generic
/contest/. The API will no longer use "the contest with id=1", but the
currently active contest as well.

The currently active contest is - for now - the contest with the latest
deadline.
2022-01-22 19:22:52 +01:00
Sebastian Lohff 7ae53b6150 Set Django to 4.0.1, add DB migrations
Now that we're somewhat Django4 compatible it's time to advertise our
newfound skill! Not all functionality has been tested, but it is
starting again.
2022-01-22 17:35:57 +01:00
Sebastian Lohff 13afffda02 staticfiles template tag libary is called static 2022-01-22 17:35:57 +01:00
Sebastian Lohff 1f39cc5b40 user.is_authenticated is no longer a method
Apparently this is now a property, so we don't need to call it. We can
just use it.
2022-01-22 17:35:57 +01:00
Sebastian Lohff 933a337eac Fix url config for Django4
url() is no longer available, has to be replaced with path() or
re_path(). For proper include() we also need an app_name specified in
the urls.py we're including.
2022-01-22 17:35:57 +01:00
Sebastian Lohff 635289ed8f Set on_delete for all ForeignKey fields
Can't believe this has not been done yet.
2022-01-22 17:35:57 +01:00
Sebastian Lohff 970a3ae517 Remove some python2 --> python3 leftovers
Remove some __future__ imports and the six compability layer (which was
not mentioned in the requirements.txt anyway).
2022-01-22 17:35:57 +01:00
Sebastian Lohff e5465632b4 Pep8 fixes
Reformat codebase a bit so it is easier to work with.
2022-01-22 17:35:57 +01:00
20 changed files with 276 additions and 159 deletions

View File

@ -1,4 +1,4 @@
from django.conf.urls import include, url from django.urls import include, path
from rest_framework import routers from rest_framework import routers
from .views import ContestViewSet, BandViewSet, FrequencyViewSet, EntryCategoryViewSet, ReferenceViewSet, QSOViewSet, \ from .views import ContestViewSet, BandViewSet, FrequencyViewSet, EntryCategoryViewSet, ReferenceViewSet, QSOViewSet, \
@ -15,6 +15,6 @@ router.register('shadowcalls', ShadowCallViewSet)
router.register('profile', UserProfileViewSet, basename='profile') router.register('profile', UserProfileViewSet, basename='profile')
urlpatterns = [ urlpatterns = [
url(r'^', include(router.urls)), path('', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
] ]

View File

@ -1,6 +1,4 @@
#!/usr/bin/env python #!/usr/bin/env python
from __future__ import print_function
# prepare environment # prepare environment
import sys import sys
sys.path.append("..") sys.path.append("..")
@ -19,7 +17,6 @@ if confirm != "YES":
print("Aborting") print("Aborting")
sys.exit(1) sys.exit(1)
from contest.models import QSO, ShadowCall, Reference, User from contest.models import QSO, ShadowCall, Reference, User
print("{0} QSOs deleted".format(*QSO.objects.all().delete())) print("{0} QSOs deleted".format(*QSO.objects.all().delete()))

View File

@ -1,9 +1,12 @@
from django.contrib import admin from django.contrib import admin
from .models import Frequency, Band, Reference, QSO, User, Contest, ShadowCall, EntryCategory from .models import Frequency, Band, Reference, QSO, User, Contest, ShadowCall, EntryCategory
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ('username', 'dncall', 'qrv2m', 'qrv70cm', 'extra2m70cm') list_display = ('username', 'dncall', 'qrv2m', 'qrv70cm', 'extra2m70cm')
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
admin.site.register(QSO) admin.site.register(QSO)
admin.site.register(Band) admin.site.register(Band)

View File

@ -1,5 +1,3 @@
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -1,16 +1,16 @@
import re
from django.shortcuts import render from django.shortcuts import render
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from .models import Contest, Band
from django.utils import timezone from django.utils import timezone
from django.contrib import messages from django.contrib import messages
from django import forms from django import forms
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from .forms import QSOFormWithTime from .forms import QSOFormWithTime
from .models import Contest, Band
import re
def parseCBR(raw): def parseCBR(raw):
""" Parse a CBR file for the CQTU """ Parse a CBR file for the CQTU
@ -20,8 +20,9 @@ def parseCBR(raw):
inside them. inside them.
""" """
kvlinere = re.compile(r"^(?P<key>[A-Z-]+):(?: (?P<value>.*))?$") kvlinere = re.compile(r"^(?P<key>[A-Z-]+):(?: (?P<value>.*))?$")
qsore = re.compile(r"^(?P<band>144|432)\s+(?P<mode>[A-Z]{2})\s+(?P<datetime>\d{4}-\d{2}-\d{2} \d{4}) (?P<call_s>[A-Z0-9/-]+)\s+(?P<rst_s>\d{2,3})\s+(?P<exc_s>[A-Z0-9-]+)\s+(?P<call_r>[A-Z0-9/-]+)\s+(?P<rst_r>\d{2,3})\s+(?P<exc_r>[A-Z0-9-]+)\s+0$") qsore = re.compile(r"^(?P<band>144|432)\s+(?P<mode>[A-Z]{2})\s+(?P<datetime>\d{4}-\d{2}-\d{2} \d{4}) "
r"(?P<call_s>[A-Z0-9/-]+)\s+(?P<rst_s>\d{2,3})\s+(?P<exc_s>[A-Z0-9-]+)\s+"
r"(?P<call_r>[A-Z0-9/-]+)\s+(?P<rst_r>\d{2,3})\s+(?P<exc_r>[A-Z0-9-]+)\s+0$")
qsoNo = 1 qsoNo = 1
info = { info = {
@ -59,14 +60,17 @@ def parseCBR(raw):
elif qsoData["band"] == "432": elif qsoData["band"] == "432":
qsoData["band"] = "70cm" qsoData["band"] = "70cm"
else: else:
raise forms.ValidationError("Error parsing band, needs to be either 144 or 432 (as we only support 2m and 70cm in this contest") raise forms.ValidationError("Error parsing band, needs to be either 144 or 432 "
"(as we only support 2m and 70cm in this contest")
info["qsos"].append(qsoData) info["qsos"].append(qsoData)
if info["call"] != qsoData["call_s"]: if info["call"] != qsoData["call_s"]:
raise forms.ValidationError("Error in line %d: qso was not made by you? (callsigns do not match)" % n) raise forms.ValidationError("Error in line %d: qso was not made by you? "
"(callsigns do not match)" % n)
if info["location"] != qsoData["exc_s"]: if info["location"] != qsoData["exc_s"]:
raise forms.ValidationError("Error in line %d: exchange does not match your location? (callsigns do not match)" % n) raise forms.ValidationError("Error in line %d: exchange does not match your location? "
"(callsigns do not match)" % n)
else: else:
raise forms.ValidationError("Error in line %d: qso was broken, regex did not match" % n) raise forms.ValidationError("Error in line %d: qso was broken, regex did not match" % n)
@ -79,8 +83,10 @@ def parseCBR(raw):
return info return info
class CBRForm(forms.Form): class CBRForm(forms.Form):
data = forms.CharField(widget=forms.Textarea, label="Cabrillo data", help_text="Paste your cabrillo file contents here") data = forms.CharField(widget=forms.Textarea, label="Cabrillo data",
help_text="Paste your cabrillo file contents here")
def clean_data(self): def clean_data(self):
rawData = self.cleaned_data["data"] rawData = self.cleaned_data["data"]
@ -88,6 +94,7 @@ class CBRForm(forms.Form):
return parsedData return parsedData
def checkCBRConsistency(contest, user, info): def checkCBRConsistency(contest, user, info):
errors = [] errors = []
qsos = [] qsos = []
@ -95,11 +102,11 @@ def checkCBRConsistency(contest, user, info):
errors.append("You are not the owner of this logfile! (%s != %s)" % (user.username, info["call"])) errors.append("You are not the owner of this logfile! (%s != %s)" % (user.username, info["call"]))
if user.ref.name != info["location"]: if user.ref.name != info["location"]:
errors.append("Location of logfile and registered exchange do not match! (%s != %s)" % (user.ref.name, info["location"])) errors.append("Location of logfile and registered exchange do not match! (%s != %s)" % (user.ref.name,
info["location"]))
for n, qsoData in enumerate(info["qsos"], 1): for n, qsoData in enumerate(info["qsos"], 1):
qsoFormData = { qsoFormData = {
#"owner": user,
"time": qsoData["datetime"], "time": qsoData["datetime"],
"call": qsoData["call_r"], "call": qsoData["call_r"],
"band": Band.objects.get(contest=contest, name=qsoData["band"]).id, "band": Band.objects.get(contest=contest, name=qsoData["band"]).id,
@ -119,6 +126,7 @@ def checkCBRConsistency(contest, user, info):
return qsos, errors return qsos, errors
@login_required @login_required
def uploadCBR(request): def uploadCBR(request):
if not request.user.ref: if not request.user.ref:
@ -149,7 +157,8 @@ def uploadCBR(request):
if cnt > 0: if cnt > 0:
messages.success(request, "%d QSOs have been saved from the cbr file" % cnt) messages.success(request, "%d QSOs have been saved from the cbr file" % cnt)
else: else:
messages.warnnig(request, "CBR file was parsed, but no QSOs could be saved, as all cointained errors.") messages.warnnig(request, "CBR file was parsed, but no QSOs could be saved, "
"as all cointained errors.")
return HttpResponseRedirect(reverse("contest:uploadCBR")) return HttpResponseRedirect(reverse("contest:uploadCBR"))
else: else:
@ -157,4 +166,6 @@ def uploadCBR(request):
else: else:
deadline = True deadline = True
return render(request, "contest/uploadCBR.html", {"deadline": deadline, 'form': form, 'verifyData': verifyData, 'verifyErrors': verifyErrors, 'save': save, 'saved': saved}) return render(request, "contest/uploadCBR.html",
{"deadline": deadline, 'form': form, 'verifyData': verifyData, 'verifyErrors': verifyErrors,
'save': save, 'saved': saved})

View File

@ -0,0 +1,7 @@
from .models import Contest
def current_contest(self):
return {
'current_contest': Contest.get_current_contest(),
}

View File

@ -1,14 +1,14 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.utils import timezone
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout from crispy_forms.layout import Submit, Layout
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from .models import User, Reference, QSO, ShadowCall, EntryCategory, Contest from .models import User, Reference, QSO, ShadowCall, EntryCategory, Contest
from .validators import CallUsernameValidator, CallLogValidator from .validators import CallUsernameValidator, CallLogValidator
class CustomUserCreationForm(UserCreationForm): class CustomUserCreationForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
@ -17,11 +17,15 @@ class CustomUserCreationForm(UserCreationForm):
username = forms.CharField(max_length=50, validators=[CallUsernameValidator()]) username = forms.CharField(max_length=50, validators=[CallUsernameValidator()])
email = forms.EmailField(required=True) email = forms.EmailField(required=True)
class UpdateRefForm(forms.Form):
existingRef = forms.ModelChoiceField(label="Existing Exchange", queryset=Reference.objects.all(), help_text="If exchange already exists, select it here.", required=False)
newRefName = forms.CharField(max_length=50, label="New Exchange", help_text="Enter name of new exchange, if we should create a new", required=False)
location = forms.CharField(max_length=128, label='Exact Location', help_text="E.g. MAR bei den Fahrstuehlen, TEL 15. OG", required=False) class UpdateRefForm(forms.Form):
existingRef = forms.ModelChoiceField(label="Existing Exchange", queryset=Reference.objects.all(),
help_text="If exchange already exists, select it here.", required=False)
newRefName = forms.CharField(max_length=50, label="New Exchange",
help_text="Enter name of new exchange, if we should create a new", required=False)
location = forms.CharField(max_length=128, label='Exact Location',
help_text="E.g. MAR bei den Fahrstuehlen, TEL 15. OG", required=False)
opName = forms.CharField(max_length=128, label='Operators', help_text="Name of operator(s)", required=False) opName = forms.CharField(max_length=128, label='Operators', help_text="Name of operator(s)", required=False)
regTime = forms.DateTimeField(label="Registration time", help_text="Time of Registration") regTime = forms.DateTimeField(label="Registration time", help_text="Time of Registration")
@ -49,8 +53,10 @@ class UpdateRefForm(forms.Form):
if not existingRef and not newRefName: if not existingRef and not newRefName:
raise forms.ValidationError("Select either an existing exchange or create a new one!") raise forms.ValidationError("Select either an existing exchange or create a new one!")
class UpdateCategoryForm(forms.Form): class UpdateCategoryForm(forms.Form):
entry = forms.ModelChoiceField(label="Entry category", queryset=EntryCategory.objects.all()) entry = forms.ModelChoiceField(label="Entry category", queryset=EntryCategory.objects.all())
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(UpdateCategoryForm, self).__init__(*args, **kwargs) super(UpdateCategoryForm, self).__init__(*args, **kwargs)
@ -68,10 +74,11 @@ class UpdateCategoryForm(forms.Form):
if contest.deadline < timezone.now(): if contest.deadline < timezone.now():
raise forms.ValidationError("The deadline for setting your contest category has passed") raise forms.ValidationError("The deadline for setting your contest category has passed")
class QSOForm(forms.ModelForm): class QSOForm(forms.ModelForm):
class Meta: class Meta:
model = QSO model = QSO
#fields = ["ownNo", "band", "call", "reportTX", "reportRX", "refStr", "otherNo", "remarks"] # fields = ["ownNo", "band", "call", "reportTX", "reportRX", "refStr", "otherNo", "remarks"]
fields = ["ownNo", "band", "call", "reportTX", "reportRX", "refStr", "remarks"] fields = ["ownNo", "band", "call", "reportTX", "reportRX", "refStr", "remarks"]
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
@ -80,16 +87,15 @@ class QSOForm(forms.ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_id = "qso-log-form" self.helper.form_id = "qso-log-form"
#self.helper.form_class = "form-inline " # self.helper.form_class = "form-inline "
#self.helper.form_class = "form-horizontal" # self.helper.form_class = "form-horizontal"
#self.helper.form_style = 'inline' # self.helper.form_style = 'inline'
#self.helper.field_template = "bootstrap3/layout/inline_field.html" # self.helper.field_template = "bootstrap3/layout/inline_field.html"
self.helper.action = reverse("contest:log") self.helper.action = reverse("contest:log")
self.helper.add_input(Submit('submit', 'Log')) self.helper.add_input(Submit('submit', 'Log'))
#self.helper.layout = Layout( # self.helper.layout = Layout(
# #*(QSOForm.Meta.fields + [ButtonHolder(Submit('submit', 'Submit', css_class='button white'))])) # #*(QSOForm.Meta.fields + [ButtonHolder(Submit('submit', 'Submit', css_class='button white'))]))
# *(QSOForm.Meta.fields + [FormActions(Submit('submit', 'Log!'))])) # *(QSOForm.Meta.fields + [FormActions(Submit('submit', 'Log!'))]))
def clean_call(self): def clean_call(self):
data = self.cleaned_data["call"].upper().strip() data = self.cleaned_data["call"].upper().strip()
@ -141,12 +147,14 @@ class QSOForm(forms.ModelForm):
if band.contest.deadline < timezone.now(): if band.contest.deadline < timezone.now():
raise forms.ValidationError("The deadline for logging and editing QSOs has passed") raise forms.ValidationError("The deadline for logging and editing QSOs has passed")
class QSOFormWithTime(QSOForm): class QSOFormWithTime(QSOForm):
class Meta: class Meta:
model = QSO model = QSO
#fields = ["time", "ownNo", "band", "call", "reportTX", "reportRX", "otherNo", "refStr", "remarks"] # fields = ["time", "ownNo", "band", "call", "reportTX", "reportRX", "otherNo", "refStr", "remarks"]
fields = ["time", "ownNo", "band", "call", "reportTX", "reportRX", "refStr", "remarks"] fields = ["time", "ownNo", "band", "call", "reportTX", "reportRX", "refStr", "remarks"]
class ShadowCallAddForm(forms.ModelForm): class ShadowCallAddForm(forms.ModelForm):
class Meta: class Meta:

View File

@ -0,0 +1,65 @@
# Generated by Django 4.0.1 on 2022-01-22 16:18
import django.contrib.auth.validators
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0020_auto_20190122_2348'),
]
operations = [
migrations.AlterField(
model_name='qso',
name='otherNo',
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1000000)], verbose_name='No-R'),
),
migrations.AlterField(
model_name='qso',
name='ownNo',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1000000)], verbose_name='No'),
),
migrations.AlterField(
model_name='user',
name='dncall',
field=models.CharField(blank=True, default='', help_text='If you have a DN call that you will offer to SWLs please enter it here', max_length=16, verbose_name='DN-Call'),
),
migrations.AlterField(
model_name='user',
name='extra2m70cm',
field=models.BooleanField(default=False, help_text='Will you bring an additional 2m/70cm TRX to lend to other participants?', verbose_name='Additional 2m/70cm TRX'),
),
migrations.AlterField(
model_name='user',
name='first_name',
field=models.CharField(blank=True, max_length=150, verbose_name='first name'),
),
migrations.AlterField(
model_name='user',
name='last_name',
field=models.CharField(blank=True, max_length=150, verbose_name='last name'),
),
migrations.AlterField(
model_name='user',
name='qrv2m',
field=models.BooleanField(default=False, help_text='Will you be QRV on 2m during the contest?', verbose_name='QRV on 2m'),
),
migrations.AlterField(
model_name='user',
name='qrv70cm',
field=models.BooleanField(default=False, help_text='Will you be QRV on 70cm during the contest?', verbose_name='QRV on 70cm'),
),
migrations.AlterField(
model_name='user',
name='regTime',
field=models.DateTimeField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name='user',
name='username',
field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.0.1 on 2022-01-22 17:52
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0021_alter_qso_otherno_alter_qso_ownno_alter_user_dncall_and_more'),
]
operations = [
migrations.AddField(
model_name='contest',
name='contestNo',
field=models.IntegerField(default=1, help_text='Running number of contest (for vanity reasons)', validators=[django.core.validators.MinValueValidator(1)]),
preserve_default=False,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.1 on 2022-01-22 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0022_contest_contestno'),
]
operations = [
migrations.AddField(
model_name='contest',
name='rulesetLink',
field=models.TextField(default='', help_text='URL to the ruleset pdf for this contest'),
preserve_default=False,
),
]

View File

@ -1,19 +1,21 @@
from __future__ import unicode_literals
import datetime import datetime
from django.db import models
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
from django.db import models
from django.db.models import Q, signals from django.db.models import Q, signals
from .validators import CallUsernameValidator
from .signals import checkForShadowCall from .signals import checkForShadowCall
from .validators import CallUsernameValidator
class Contest(models.Model): class Contest(models.Model):
name = models.CharField(max_length=20) name = models.CharField(max_length=20)
shortName = models.CharField(max_length=20, unique=True) shortName = models.CharField(max_length=20, unique=True)
callQrg = models.ForeignKey("Frequency", models.SET_NULL, null=True, blank=True) contestNo = models.IntegerField(validators=[MinValueValidator(1)],
help_text="Running number of contest (for vanity reasons)")
rulesetLink = models.TextField(help_text="URL to the ruleset pdf for this contest")
callQrg = models.ForeignKey("Frequency", on_delete=models.SET_NULL, null=True, blank=True)
deadline = models.DateTimeField() deadline = models.DateTimeField()
qsoStartTime = models.DateTimeField() qsoStartTime = models.DateTimeField()
@ -24,7 +26,12 @@ class Contest(models.Model):
@classmethod @classmethod
def get_current_contest(cls): def get_current_contest(cls):
return cls.objects.get(id=1) # Currently the contest with the latest deadline is the active one
# This definitely has potential for improvement, but it's better than a hardcoded contest
contests = cls.objects.order_by("-deadline")
if len(contests) > 0:
return contests[0]
return None
class Reference(models.Model): class Reference(models.Model):
@ -34,6 +41,7 @@ class Reference(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
class EntryCategory(models.Model): class EntryCategory(models.Model):
name = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=64, unique=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
@ -41,9 +49,10 @@ class EntryCategory(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
class ShadowCall(models.Model): class ShadowCall(models.Model):
username = models.CharField(max_length=20, unique=True, db_index=True, validators=[CallUsernameValidator()]) username = models.CharField(max_length=20, unique=True, db_index=True, validators=[CallUsernameValidator()])
ref = models.ForeignKey(Reference, models.SET_NULL,null=True, blank=True) ref = models.ForeignKey(Reference, models.SET_NULL, null=True, blank=True)
location = models.CharField(max_length=128, default="", blank=True) location = models.CharField(max_length=128, default="", blank=True)
opName = models.CharField(max_length=128, default="", blank=True) opName = models.CharField(max_length=128, default="", blank=True)
@ -52,6 +61,7 @@ class ShadowCall(models.Model):
def __str__(self): def __str__(self):
return self.username return self.username
class User(AbstractUser): class User(AbstractUser):
ref = models.ForeignKey(Reference, models.SET_NULL, null=True, blank=True) ref = models.ForeignKey(Reference, models.SET_NULL, null=True, blank=True)
cat = models.ForeignKey(EntryCategory, models.SET_NULL, null=True, blank=True) cat = models.ForeignKey(EntryCategory, models.SET_NULL, null=True, blank=True)
@ -67,17 +77,18 @@ class User(AbstractUser):
# extra profile stuff so DL7BST can sleep well without his doodles # extra profile stuff so DL7BST can sleep well without his doodles
editedProfile = models.BooleanField(default=False) editedProfile = models.BooleanField(default=False)
dncall = models.CharField(max_length=16, default='', blank=True, dncall = models.CharField(max_length=16, default='', blank=True,
verbose_name="DN-Call", verbose_name="DN-Call",
help_text="If you have a DN call that you will offer to SWLs please enter it here") help_text="If you have a DN call that you will offer to SWLs please enter it here")
qrv2m = models.BooleanField(default=False, qrv2m = models.BooleanField(default=False,
verbose_name="QRV on 2m", verbose_name="QRV on 2m",
help_text="Will you be QRV on 2m during the contest?") help_text="Will you be QRV on 2m during the contest?")
qrv70cm = models.BooleanField(default=False, qrv70cm = models.BooleanField(default=False,
verbose_name="QRV on 70cm", verbose_name="QRV on 70cm",
help_text="Will you be QRV on 70cm during the contest?") help_text="Will you be QRV on 70cm during the contest?")
extra2m70cm = models.BooleanField(default=False, extra2m70cm = models.BooleanField(default=False,
verbose_name="Additional 2m/70cm TRX", verbose_name="Additional 2m/70cm TRX",
help_text="Will you bring an additional 2m/70cm TRX to lend to other participants?") help_text="Will you bring an additional 2m/70cm TRX to lend to "
"other participants?")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(User, self).__init__(*args, **kwargs) super(User, self).__init__(*args, **kwargs)
@ -124,27 +135,32 @@ class User(AbstractUser):
"qsoCount": qsos.count(), "qsoCount": qsos.count(),
"refCount": len(refs) "refCount": len(refs)
} }
signals.post_save.connect(checkForShadowCall, sender=User) signals.post_save.connect(checkForShadowCall, sender=User)
class Band(models.Model): class Band(models.Model):
name = models.CharField(max_length=10) name = models.CharField(max_length=10)
contest = models.ForeignKey(Contest) contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
return self.name return self.name
class Frequency(models.Model): class Frequency(models.Model):
# qrg # qrg
# band # band
channel = models.CharField(max_length=3) channel = models.CharField(max_length=3)
qrg = models.DecimalField(max_digits=7, decimal_places=3) qrg = models.DecimalField(max_digits=7, decimal_places=3)
band = models.ForeignKey(Band) band = models.ForeignKey(Band, on_delete=models.CASCADE)
note = models.CharField(max_length=50, blank=True) note = models.CharField(max_length=50, blank=True)
def __str__(self): def __str__(self):
return "Channel %s: %s MHz" % (self.channel, self.qrg) return "Channel %s: %s MHz" % (self.channel, self.qrg)
class QSO(models.Model): class QSO(models.Model):
MAX_NO_VALUE = 1000000 MAX_NO_VALUE = 1000000
reportValidator = RegexValidator("[1-5][1-9]") reportValidator = RegexValidator("[1-5][1-9]")
@ -154,11 +170,11 @@ class QSO(models.Model):
["owner", "call"], ["owner", "call"],
] ]
owner = models.ForeignKey(User, db_index=True) owner = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True)
time = models.DateTimeField(blank=True) time = models.DateTimeField(blank=True)
call = models.CharField(max_length=20, db_index=True) call = models.CharField(max_length=20, db_index=True)
callRef = models.ForeignKey(User, models.SET_NULL, related_name='qsoref', null=True, blank=True, default=None) callRef = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='qsoref', null=True, blank=True, default=None)
band = models.ForeignKey(Band) band = models.ForeignKey(Band, on_delete=models.CASCADE)
reportTX = models.CharField(max_length=7, default=59, verbose_name='RS-S', validators=[reportValidator]) reportTX = models.CharField(max_length=7, default=59, verbose_name='RS-S', validators=[reportValidator])
reportRX = models.CharField(max_length=7, default=59, verbose_name='RS-R', validators=[reportValidator]) reportRX = models.CharField(max_length=7, default=59, verbose_name='RS-R', validators=[reportValidator])
@ -168,13 +184,13 @@ class QSO(models.Model):
validators=[MinValueValidator(1), MaxValueValidator(MAX_NO_VALUE)]) validators=[MinValueValidator(1), MaxValueValidator(MAX_NO_VALUE)])
refStr = models.CharField(max_length=20, verbose_name="EXC") refStr = models.CharField(max_length=20, verbose_name="EXC")
ref = models.ForeignKey(Reference, models.SET_NULL, null=True, blank=True) ref = models.ForeignKey(Reference, on_delete=models.SET_NULL, null=True, blank=True)
remarks = models.CharField(max_length=50, blank=True, default=None) remarks = models.CharField(max_length=50, blank=True, default=None)
cfmdQSO = models.ForeignKey("QSO", models.SET_NULL, null=True, blank=True, default=None) cfmdQSO = models.ForeignKey("QSO", on_delete=models.SET_NULL, null=True, blank=True, default=None)
CFMD_SEC = 5*60 CFMD_SEC = 5 * 60
def checkQSOData(self): def checkQSOData(self):
""" Match strdata to log rows. Only call, if you intent to save this object if we return True! """ """ Match strdata to log rows. Only call, if you intent to save this object if we return True! """
@ -213,10 +229,10 @@ class QSO(models.Model):
# check if this still checks out # check if this still checks out
q = self.cfmdQSO q = self.cfmdQSO
if abs((self.time - q.time).total_seconds()) <= self.CFMD_SEC and \ if abs((self.time - q.time).total_seconds()) <= self.CFMD_SEC and \
self.ref and self.owner.ref and self.callRef and q.callRef and \ self.ref and self.owner.ref and self.callRef and q.callRef and \
q.owner == self.callRef and q.callRef == self.owner and \ q.owner == self.callRef and q.callRef == self.owner and \
self.ref == q.owner.ref and self.owner.ref == q.ref and \ self.ref == q.owner.ref and self.owner.ref == q.ref and \
self.band == q.band: self.band == q.band:
# checks out # checks out
pass pass
else: else:
@ -228,7 +244,8 @@ class QSO(models.Model):
if self.ref and self.callRef and self.callRef.ref and not self.cfmdQSO: if self.ref and self.callRef and self.callRef.ref and not self.cfmdQSO:
# look for a matching line # look for a matching line
q = QSO.objects.filter( q = QSO.objects.filter(
(Q(time__lte=self.time + datetime.timedelta(seconds=self.CFMD_SEC)) & Q(time__gte=self.time - datetime.timedelta(seconds=self.CFMD_SEC))), (Q(time__lte=self.time + datetime.timedelta(seconds=self.CFMD_SEC)) &
Q(time__gte=self.time - datetime.timedelta(seconds=self.CFMD_SEC))),
owner=self.callRef, owner=self.callRef,
callRef=self.owner, callRef=self.owner,
owner__ref=self.ref, owner__ref=self.ref,
@ -250,4 +267,6 @@ class QSO(models.Model):
super(QSO, self).save(*args, **kwargs) super(QSO, self).save(*args, **kwargs)
def __str__(self): def __str__(self):
return "QSO no %s at %s on band %s from %s with %s@%s %s/%s" % (self.ownNo, self.time.strftime("%H:%M"), self.band, self.owner.username, self.call, self.refStr, self.reportTX, self.reportRX) return "QSO no %s at %s on band %s from %s with %s@%s %s/%s" % (self.ownNo, self.time.strftime("%H:%M"),
self.band, self.owner.username, self.call,
self.refStr, self.reportTX, self.reportRX)

View File

@ -1,35 +1,22 @@
"""cqtu URL Configuration from django.urls import re_path
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import url
import contest.views as contest_views import contest.views as contest_views
from contest.cbrparser import uploadCBR from contest.cbrparser import uploadCBR
app_name = 'context'
urlpatterns = [ urlpatterns = [
url(r'^$', contest_views.contestIndex, name='index'), re_path(r'^$', contest_views.contestIndex, name='index'),
url(r'^regref/$', contest_views.registerRefs, name='registerRefs'), re_path(r'^regref/$', contest_views.registerRefs, name='registerRefs'),
url(r'^regref/edit/(?P<uid>\d+)/$', contest_views.updateRef, {"shadow": False}, name='updateRef'), re_path(r'^regref/edit/(?P<uid>\d+)/$', contest_views.updateRef, {"shadow": False}, name='updateRef'),
url(r'^regref/shadow/edit/(?P<uid>\d+)/$', contest_views.updateRef, {"shadow": True}, name='updateShadowRef'), re_path(r'^regref/shadow/edit/(?P<uid>\d+)/$', contest_views.updateRef, {"shadow": True}, name='updateShadowRef'),
url(r'^regref/qsos/all/$', contest_views.viewAllQSOs, name='viewAllQSOs'), re_path(r'^regref/qsos/all/$', contest_views.viewAllQSOs, name='viewAllQSOs'),
url(r'^regref/qsos/user/(?P<uid>\d+)/$', contest_views.viewUserQSOs, name='viewUserQSOs'), re_path(r'^regref/qsos/user/(?P<uid>\d+)/$', contest_views.viewUserQSOs, name='viewUserQSOs'),
url(r'^overview/$', contest_views.overview, name='overview'), re_path(r'^overview/$', contest_views.overview, name='overview'),
url(r'^log/$', contest_views.log, name='log'), re_path(r'^log/$', contest_views.log, name='log'),
url(r'^log/edit/(?P<qsoid>\d+)/$', contest_views.logEdit, name='logEdit'), re_path(r'^log/edit/(?P<qsoid>\d+)/$', contest_views.logEdit, name='logEdit'),
url(r'^log/delete/(?P<qsoid>\d+)/$', contest_views.logDelete, name='logDelete'), re_path(r'^log/delete/(?P<qsoid>\d+)/$', contest_views.logDelete, name='logDelete'),
url(r'^uploadcbr/$', uploadCBR, name='uploadCBR'), re_path(r'^uploadcbr/$', uploadCBR, name='uploadCBR'),
url(r'^regref/recheckqsos/$', contest_views.recheckAllQSOs, name='recheckAllQSOs'), re_path(r'^regref/recheckqsos/$', contest_views.recheckAllQSOs, name='recheckAllQSOs'),
] ]

View File

@ -1,27 +1,25 @@
from django.core import validators from django.core import validators
from django.utils import six
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
import re import re
@deconstructible @deconstructible
class CallUsernameValidator(validators.RegexValidator): class CallUsernameValidator(validators.RegexValidator):
#regex = r'^[\w.@+-]+$'
regex = r'^(?:[A-Z]+/)?[A-Z]{1,2}[0-9][A-Z]{1,4}(?:-[0-9])??$' regex = r'^(?:[A-Z]+/)?[A-Z]{1,2}[0-9][A-Z]{1,4}(?:-[0-9])??$'
message = _( message = _(
'Enter a valid Callsign as Username, ALL UPPERCASE, if needed with -1 / -2,' 'Enter a valid Callsign as Username, ALL UPPERCASE, if needed with -1 / -2,'
'e.g. DL7BST, DN1BER-1, DL/OE1FOO.' 'e.g. DL7BST, DN1BER-1, DL/OE1FOO.'
) )
flags = re.ASCII if six.PY3 else 0 flags = re.ASCII
@deconstructible @deconstructible
class CallLogValidator(validators.RegexValidator): class CallLogValidator(validators.RegexValidator):
#regex = r'^[\w.@+-]+$'
regex = r'^(?:[A-Z]+/)?[A-Z]{1,2}[0-9][A-Z]{1,4}(?:-[0-9])?(?:/[A-Z]{1,3})?$' regex = r'^(?:[A-Z]+/)?[A-Z]{1,2}[0-9][A-Z]{1,4}(?:-[0-9])?(?:/[A-Z]{1,3})?$'
message = _( message = _(
'Enter a valid callsign, ALL UPPERCASE, if needed with -1 / -2,' 'Enter a valid callsign, ALL UPPERCASE, if needed with -1 / -2,'
'e.g. DL7BST, DN1BER-1, DL/OE1FOO, DN1FTW-1/p' 'e.g. DL7BST, DN1BER-1, DL/OE1FOO, DN1FTW-1/p'
) )
flags = re.ASCII if six.PY3 else 0 flags = re.ASCII

View File

@ -1,8 +1,8 @@
from django.shortcuts import render, get_object_or_404 import datetime
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
#from django.db.models import Q
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.contrib import messages from django.contrib import messages
@ -11,19 +11,18 @@ from django.contrib.auth import login as auth_login
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.utils import timezone from django.utils import timezone
import datetime
from .models import User, Contest, Frequency, Reference, QSO, ShadowCall from .models import User, Contest, Frequency, Reference, QSO, ShadowCall
from .forms import UpdateRefForm, QSOForm, QSOFormWithTime, CustomUserCreationForm, ShadowCallAddForm, UpdateCategoryForm from .forms import UpdateRefForm, QSOForm, QSOFormWithTime, CustomUserCreationForm, ShadowCallAddForm, \
UpdateCategoryForm
def index(request): def index(request):
if request.user.is_authenticated(): if request.user.is_authenticated:
return HttpResponseRedirect(reverse("contest:index")) return HttpResponseRedirect(reverse("contest:index"))
return render(request, "index.html", {"loginForm": AuthenticationForm()}) return render(request, "index.html", {"loginForm": AuthenticationForm()})
@login_required @login_required
def contestIndex(request): def contestIndex(request):
qsoform = QSOForm(request.user) qsoform = QSOForm(request.user)
@ -32,6 +31,7 @@ def contestIndex(request):
return render(request, 'contest/index.html', {"qsoform": qsoform, "contest": contest, "qrgs": qrgs}) return render(request, 'contest/index.html', {"qsoform": qsoform, "contest": contest, "qrgs": qrgs})
@login_required @login_required
def log(request): def log(request):
if not request.user.ref: if not request.user.ref:
@ -68,9 +68,9 @@ def log(request):
form = QSOForm(request.user, initial=data) form = QSOForm(request.user, initial=data)
form.helper.form_tag = False form.helper.form_tag = False
return render(request, 'contest/log.html', {'form': form, 'qsos': qsos}) return render(request, 'contest/log.html', {'form': form, 'qsos': qsos})
@login_required @login_required
def logEdit(request, qsoid): def logEdit(request, qsoid):
if not request.user.ref: if not request.user.ref:
@ -91,6 +91,7 @@ def logEdit(request, qsoid):
return render(request, 'contest/logEdit.html', {'form': form, "qso": qso}) return render(request, 'contest/logEdit.html', {'form': form, "qso": qso})
def logDelete(request, qsoid): def logDelete(request, qsoid):
if not request.user.ref: if not request.user.ref:
return HttpResponseRedirect(reverse("contest:index")) return HttpResponseRedirect(reverse("contest:index"))
@ -109,7 +110,6 @@ def logDelete(request, qsoid):
return render(request, 'contest/logDelete.html', {"qso": qso}) return render(request, 'contest/logDelete.html', {"qso": qso})
@staff_member_required @staff_member_required
def registerRefs(request): def registerRefs(request):
allUser = User.objects.all() allUser = User.objects.all()
@ -127,7 +127,9 @@ def registerRefs(request):
else: else:
shadowForm = ShadowCallAddForm() shadowForm = ShadowCallAddForm()
return render(request, 'contest/registerRefs.html', {'alluser': allUser, "qsos": qsos, "shadowForm": shadowForm, "shadows": shadows}) return render(request, 'contest/registerRefs.html',
{'alluser': allUser, "qsos": qsos, "shadowForm": shadowForm, "shadows": shadows})
def getPage(paginator, pageNo): def getPage(paginator, pageNo):
try: try:
@ -153,6 +155,7 @@ def recheckAllQSOs(request):
return render(request, "contest/checkAllQSOs.html", {}) return render(request, "contest/checkAllQSOs.html", {})
@staff_member_required @staff_member_required
def viewUserQSOs(request, uid, page=1): def viewUserQSOs(request, uid, page=1):
user = get_object_or_404(User, id=uid) user = get_object_or_404(User, id=uid)
@ -162,7 +165,9 @@ def viewUserQSOs(request, uid, page=1):
userRefs = set(map(lambda _x: _x["refStr"], user.qso_set.filter(ref__isnull=False).values("ref", "refStr"))) userRefs = set(map(lambda _x: _x["refStr"], user.qso_set.filter(ref__isnull=False).values("ref", "refStr")))
return render(request, "contest/viewUserQSOs.html", {'owner': user, 'qsos': qsos, 'qsoPage': qsoPage, 'userRefs': userRefs}) return render(request, "contest/viewUserQSOs.html",
{'owner': user, 'qsos': qsos, 'qsoPage': qsoPage, 'userRefs': userRefs})
@staff_member_required @staff_member_required
def updateRef(request, shadow, uid): def updateRef(request, shadow, uid):
@ -208,6 +213,7 @@ def updateRef(request, shadow, uid):
return render(request, 'contest/updateRef.html', {'userobj': user, 'form': form, "shadow": shadow}) return render(request, 'contest/updateRef.html', {'userobj': user, 'form': form, "shadow": shadow})
@staff_member_required @staff_member_required
def viewAllQSOs(request, page=1): def viewAllQSOs(request, page=1):
qsos = QSO.objects.all().order_by("-time") qsos = QSO.objects.all().order_by("-time")
@ -216,12 +222,14 @@ def viewAllQSOs(request, page=1):
return render(request, 'contest/viewAllQSOs.html', {'qsoPage': qsoPage}) return render(request, 'contest/viewAllQSOs.html', {'qsoPage': qsoPage})
def overview(request): def overview(request):
# FIXME: Hardcoded for cqtu... everywhere # FIXME: Hardcoded for cqtu... everywhere
c = Contest.objects.get(id=1) c = Contest.objects.get(id=1)
qrgs = Frequency.objects.filter(band__contest=c).order_by("channel") qrgs = Frequency.objects.filter(band__contest=c).order_by("channel")
return render(request, 'contest/overview.html', {'contest': c, 'qrgs': qrgs}) return render(request, 'contest/overview.html', {'contest': c, 'qrgs': qrgs})
def register(request): def register(request):
form = None form = None
if request.method == 'POST': if request.method == 'POST':
@ -237,6 +245,7 @@ def register(request):
return render(request, 'registration/register.html', {"form": form}) return render(request, 'registration/register.html', {"form": form})
@login_required @login_required
def profile(request): def profile(request):
pwForm = None pwForm = None

View File

@ -40,6 +40,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.humanize',
'crispy_forms', 'crispy_forms',
'rest_framework', 'rest_framework',
'django_filters', 'django_filters',
@ -72,6 +73,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'contest.context_processors.current_contest',
], ],
}, },
}, },
@ -107,12 +109,6 @@ AUTH_PASSWORD_VALIDATORS = [
'min_length': 4, 'min_length': 4,
}, },
}, },
#{
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
#},
#{
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
#},
] ]

View File

@ -1,43 +1,18 @@
"""cqtu URL Configuration from django.urls import include, path
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import url, include
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
#from django.views.generic.edit import CreateView
#from django.contrib.auth.forms import UserCreationForm
#from contest.forms import CustomUserCreationForm
from contest.views import index, register, profile from contest.views import index, register, profile
urlpatterns = [ urlpatterns = [
url('^$', index, name="index"), path('', index, name="index"),
url('^cqtufm2019/', include('contest.urls', namespace='contest')), path('contest/', include('contest.urls', namespace='contest')),
url(r'^admin/', admin.site.urls), path('admin/', admin.site.urls),
url(r'^login/$', auth_views.login, name='login'), path('login/', auth_views.LoginView.as_view(), name='login'),
url(r'^logout/$', auth_views.logout, {'next_page': '/'}, name='logout'), path('logout/', auth_views.LogoutView.as_view(), {'next_page': '/'}, name='logout'),
url(r'^register/$', register, name='register'), path('register/', register, name='register'),
url(r'^profile/$', profile, name='profile'), path('profile/', profile, name='profile'),
url(r'^api/', include('api.urls')), path('api/', include('api.urls')),
#url(r'^register/$', CreateView.as_view(
# template_name='registration/register.html',
# form_class=CustomUserCreationForm,
# success_url='/',
#), name='register'),
] ]

View File

@ -1,4 +1,4 @@
Django<1.12 Django==4.0.1
django-crispy-forms django-crispy-forms
django-rest-framework django-rest-framework
django-filter django-filter

View File

@ -8,9 +8,9 @@
<meta name="description" content=""> <meta name="description" content="">
<meta name="author" content=""> <meta name="author" content="">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
{% load staticfiles %} {% load static %}
<title>CQTUFM2019 - CQ TU FM Contest 2019</title> <title>{{ current_contest.name }}</title>
<!-- Bootstrap core CSS --> <!-- Bootstrap core CSS -->
<link href="{% static "css/bootstrap.min.css" %}" rel="stylesheet"> <link href="{% static "css/bootstrap.min.css" %}" rel="stylesheet">
@ -35,7 +35,7 @@
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
</button> </button>
<a class="navbar-brand" href="{% url "index" %}">CQ TU FM 2019</a> <a class="navbar-brand" href="{% url "index" %}">{{ current_contest.name | default:"NO CONTEST" }}</a>
</div> </div>
<div id="navbar" class="navbar-collapse collapse"> <div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
@ -88,7 +88,7 @@
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<p class="text-muted">CQ TU FM 2019, a <a href="http://dk0tu.de/">DK0TU</a> product</p> <p class="text-muted">{{ current_contest.name }} powered by <a href="https://git.dk0tu.de/cqtu/cqtu-webif/">CQTU Contest Webinterface</a> - a <a href="http://dk0tu.de/">DK0TU</a> product</p>
</div> </div>
</footer> </footer>

View File

@ -4,7 +4,7 @@
<p> <p>
Welcome to {{ contest.name }}! Welcome to {{ contest.name }}!
<p/> <p/>
<p>Here you can find a short overview over the rules, but note that this is not a replacement for the <a href="http://dk0tu.de/contests/cqtu/CQTU_2017-02-02_FM_Rules+Log.300dpi.pdf">complete ruleset</a>.</p> <p>Here you can find a short overview over the rules, but note that this is not a replacement for the <a href="{{ current_contest.rulesetLink }}">complete ruleset</a>.</p>
<p> <p>
<ul> <ul>
<li>Change frequency after a successful CQ-call! If you answered, feel free to call CQ on this frequency.</li> <li>Change frequency after a successful CQ-call! If you answered, feel free to call CQ on this frequency.</li>

View File

@ -1,12 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load humanize %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<p class="lead">Hello and welcome to the 4th DK0TU CQ TU contest, the CQ TU 2019!</p> {% if current_contest %}
<p class="lead">Hello and welcome to the {{ current_contest.contestNo | ordinal }} DK0TU CQ TU contest, the {{ current_contest.name }}!</p>
{% else %}
<p class="lead">ERROR: No contest set in admin area! If you're an admin <a href="/admin/">go create one</a>.</p>
{% endif %}
</div> </div>
</div> </div>