diff --git a/docs/help/positions/apply.rst b/docs/help/positions/apply.rst new file mode 100644 index 00000000..a4c50632 --- /dev/null +++ b/docs/help/positions/apply.rst @@ -0,0 +1,25 @@ +==================== +Apply for a position +==================== + +Active members can now see a list of open leadership positions created by the +Executive Board. + +Viewing Positions +----------------- +To view a list of open positions, go to the "Members" dropdown and find the +"Open Positions" link. Clicking that link will bring you to a list of positions +that are currently looking for interested members. + +.. hint: + You can click the name of a position to view more information + +Applying +-------- +Depending on how the Executive board has created the position, you can do one of +two things to express your interest. If they have created a form for interested +members, there will be a green "Apply" button that will take you to the form. If +they haven't created a form, look at the position description for more +information. + +`Last modified: 13 December 2021` diff --git a/docs/help/positions/create.rst b/docs/help/positions/create.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/help/user-guides.rst b/docs/help/user-guides.rst index 5ad8814a..8312f17f 100644 --- a/docs/help/user-guides.rst +++ b/docs/help/user-guides.rst @@ -81,6 +81,7 @@ Training training/request-pit training/training-records + Slack ^^^^^ @@ -88,3 +89,11 @@ Slack :maxdepth: 1 report-slack + +Positions +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + positions/apply + positions/create \ No newline at end of file diff --git a/events/views/indices.py b/events/views/indices.py index aafbea4b..e07f3176 100644 --- a/events/views/indices.py +++ b/events/views/indices.py @@ -13,6 +13,7 @@ from events.models import BaseEvent, Workshop, CrewAttendanceRecord from helpers.challenges import is_officer from pages.models import OnboardingScreen, OnboardingRecord +from positions.models import Position # FRONT 3 PAGES @@ -80,6 +81,9 @@ def admin(request, msg=None): datetime_end__gte=(now - datetime.timedelta(hours=3))).distinct() context['selfcrew_events'] = selfcrew_events + open_positions = Position.objects.filter(closes__gte=timezone.now()).count() + context['open_positions'] = open_positions + return render(request, 'admin.html', context) diff --git a/lnldb/settings.py b/lnldb/settings.py index 0f56877b..1db74a46 100644 --- a/lnldb/settings.py +++ b/lnldb/settings.py @@ -323,6 +323,7 @@ def from_runtime(*x): 'api', 'rt', 'slack', + 'positions', 'bootstrap3', 'crispy_forms', diff --git a/lnldb/urls.py b/lnldb/urls.py index 68594005..fe4c1180 100644 --- a/lnldb/urls.py +++ b/lnldb/urls.py @@ -54,6 +54,7 @@ url(r'^mdm/', include(('devices.urls.mdm', 'mdm'), namespace="mdm")), url(r'^support/', include(('rt.urls', 'support'), namespace='support')), url(r'', include(('pages.urls', 'pages'), namespace='pages')), + url(r'^db/positions/', include('positions.urls', 'positions')), # special urls url(r'^db/$', db_home, name="home"), diff --git a/positions/__init__.py b/positions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/positions/admin.py b/positions/admin.py new file mode 100644 index 00000000..d895a901 --- /dev/null +++ b/positions/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from .models import Position + +# Register your models here. + +admin.site.register(Position) diff --git a/positions/apps.py b/positions/apps.py new file mode 100644 index 00000000..490dbf89 --- /dev/null +++ b/positions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PositionsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'positions' diff --git a/positions/forms.py b/positions/forms.py new file mode 100644 index 00000000..85b6b30b --- /dev/null +++ b/positions/forms.py @@ -0,0 +1,14 @@ +from django import forms +from .models import Position +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Submit + +class UpdateCreatePosition(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.add_input(Submit('submit', 'Submit')) + class Meta: + model = Position + fields = ('name', 'description', 'position_start', 'position_end', + 'closes', 'reports_to', 'application_form') diff --git a/positions/migrations/0001_initial.py b/positions/migrations/0001_initial.py new file mode 100644 index 00000000..0bdfd865 --- /dev/null +++ b/positions/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.13 on 2021-11-03 20:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Position', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=32, verbose_name='Position Name')), + ('description', models.TextField(verbose_name='Position Description')), + ('position_start', models.DateField(verbose_name='Term Start')), + ('position_end', models.DateField(verbose_name='Term End')), + ('reports_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/positions/migrations/0002_auto_20211104_1125.py b/positions/migrations/0002_auto_20211104_1125.py new file mode 100644 index 00000000..03ea2a30 --- /dev/null +++ b/positions/migrations/0002_auto_20211104_1125.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.13 on 2021-11-04 15:25 + +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('positions', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='position', + name='application_form', + field=models.CharField(default='', max_length=128, verbose_name='Link to external application form'), + preserve_default=False, + ), + migrations.AddField( + model_name='position', + name='closes', + field=models.DateTimeField(default=datetime.datetime.now, verbose_name='Applications Close'), + preserve_default=False, + ), + migrations.CreateModel( + name='ApplicationInstance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('applicant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('position', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='positions.position')), + ], + ), + ] diff --git a/positions/migrations/0003_auto_20211111_1309.py b/positions/migrations/0003_auto_20211111_1309.py new file mode 100644 index 00000000..2e93d925 --- /dev/null +++ b/positions/migrations/0003_auto_20211111_1309.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.13 on 2021-11-11 18:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('positions', '0002_auto_20211104_1125'), + ] + + operations = [ + migrations.AlterField( + model_name='position', + name='closes', + field=models.DateField(blank=True, null=True, verbose_name='Applications Close'), + ), + migrations.AlterField( + model_name='position', + name='name', + field=models.CharField(max_length=64, verbose_name='Position Name'), + ), + ] diff --git a/positions/migrations/0004_auto_20211111_1419.py b/positions/migrations/0004_auto_20211111_1419.py new file mode 100644 index 00000000..88b948b6 --- /dev/null +++ b/positions/migrations/0004_auto_20211111_1419.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.13 on 2021-11-11 19:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('positions', '0003_auto_20211111_1309'), + ] + + operations = [ + migrations.AlterField( + model_name='position', + name='reports_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/positions/migrations/0005_auto_20211112_1115.py b/positions/migrations/0005_auto_20211112_1115.py new file mode 100644 index 00000000..5d6b3d25 --- /dev/null +++ b/positions/migrations/0005_auto_20211112_1115.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.13 on 2021-11-12 16:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('positions', '0004_auto_20211111_1419'), + ] + + operations = [ + migrations.AlterModelOptions( + name='position', + options={'permissions': [('apply', 'Can apply for positions')]}, + ), + ] diff --git a/positions/migrations/0006_delete_applicationinstance.py b/positions/migrations/0006_delete_applicationinstance.py new file mode 100644 index 00000000..aeb95aed --- /dev/null +++ b/positions/migrations/0006_delete_applicationinstance.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1.13 on 2021-11-12 17:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('positions', '0005_auto_20211112_1115'), + ] + + operations = [ + migrations.DeleteModel( + name='ApplicationInstance', + ), + ] diff --git a/positions/migrations/0007_auto_20211114_1403.py b/positions/migrations/0007_auto_20211114_1403.py new file mode 100644 index 00000000..9737a8ab --- /dev/null +++ b/positions/migrations/0007_auto_20211114_1403.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.13 on 2021-11-14 19:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('positions', '0006_delete_applicationinstance'), + ] + + operations = [ + migrations.AlterField( + model_name='position', + name='application_form', + field=models.URLField(max_length=128, verbose_name='Link to external application form'), + ), + migrations.AlterField( + model_name='position', + name='closes', + field=models.DateTimeField(blank=True, null=True, verbose_name='Applications Close'), + ), + ] diff --git a/positions/migrations/0008_auto_20211213_1256.py b/positions/migrations/0008_auto_20211213_1256.py new file mode 100644 index 00000000..07301e90 --- /dev/null +++ b/positions/migrations/0008_auto_20211213_1256.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.13 on 2021-12-13 17:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('positions', '0007_auto_20211114_1403'), + ] + + operations = [ + migrations.AlterField( + model_name='position', + name='application_form', + field=models.URLField(blank=True, max_length=128, null=True, verbose_name='Link to external application form'), + ), + ] diff --git a/positions/migrations/__init__.py b/positions/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/positions/models.py b/positions/models.py new file mode 100644 index 00000000..81ef3b45 --- /dev/null +++ b/positions/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone + +import datetime + +# Create your models here. + +class Position(models.Model): + """ + Describes a leadership position for a specific time. A new position instance + should be created every time one needs to be filled. + """ + name = models.CharField(verbose_name="Position Name", max_length=64, + null=False, blank=False) + description = models.TextField(verbose_name="Position Description", + null=False, blank=False) + position_start = models.DateField(verbose_name="Term Start", null=False, + blank=False) + position_end = models.DateField(verbose_name="Term End", null=False, + blank=False) + reports_to = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, + blank=True, null=True) + closes = models.DateTimeField(verbose_name="Applications Close", null=True, + blank=True) + application_form = models.URLField(verbose_name="Link to external application form", null=True, blank=True, max_length=128) + + def __str__(self): + return f"{self.name}" + + class Meta: + permissions = [ + ('apply', 'Can apply for positions') + ] + + def is_open(self): + return self.closes >= timezone.now() + diff --git a/positions/tests.py b/positions/tests.py new file mode 100644 index 00000000..fcc9182a --- /dev/null +++ b/positions/tests.py @@ -0,0 +1,84 @@ +from django.test import TestCase +from django.utils import timezone +import datetime + +from data.tests.util import ViewTestCase +from django.contrib.auth.models import Permission +from django.urls.base import reverse + + +from .models import Position + +# Create your tests here. + +class PositionModelTests(TestCase): + + def setUp(self): + self.position = Position(name="Test", description="Test", + position_start=timezone.now() + timezone.timedelta(days=-2), + position_end=timezone.now(), + closes=timezone.now(), application_form="https://example.com") + + def test_closed(self): + today = timezone.now() + yesterday = today + timezone.timedelta(days=-1) + tomorrow = today + timezone.timedelta(days=1) + + self.position.closes = today + self.position.save() + self.assertFalse(self.position.is_open()) + + self.position.closes = yesterday + self.position.save() + self.assertFalse(self.position.is_open()) + + self.position.closes = tomorrow + self.position.save() + self.assertTrue(self.position.is_open()) + +class PositionViewTests(ViewTestCase): + def setUp(self): + super(PositionViewTests, self).setUp() + + self.position = Position.objects.create(name="Test", description="Test", + position_start=timezone.now() + timezone.timedelta(days=-2), + position_end=timezone.now() + timezone.timedelta(days=10), + closes = timezone.now() + timezone.timedelta(days=1), + application_form="https://example.com") + + def test_listposition(self): + # Should not be able to view positions by default + self.assertOk(self.client.get(reverse("positions:list")), 403) + + perm = Permission.objects.get(codename="apply") + self.user.user_permissions.add(perm) + + self.assertOk(self.client.get(reverse("positions:list"))) + + def test_createposition(self): + self.assertOk(self.client.get(reverse("positions:create")), 403) + + perm = Permission.objects.get(codename="add_position") + self.user.user_permissions.add(perm) + + self.assertOk(self.client.get(reverse("positions:create"))) + + def test_viewposition(self): + self.assertOk(self.client.get(reverse("positions:detail", + args=[self.position.pk])), 403) + + perm = Permission.objects.get(codename="apply") + self.user.user_permissions.add(perm) + + self.assertOk(self.client.get(reverse("positions:detail", + args=[self.position.pk]))) + + def test_updateposition(self): + self.assertOk(self.client.get(reverse("positions:update", + args=[self.position.pk])), 403) + + perm = Permission.objects.get(codename="change_position") + self.user.user_permissions.add(perm) + + self.assertOk(self.client.get(reverse("positions:update", + args=[self.position.pk]))) diff --git a/positions/urls.py b/positions/urls.py new file mode 100644 index 00000000..bb3aa187 --- /dev/null +++ b/positions/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +app_name="positions" + +urlpatterns = [ + path('create/', views.CreatePosition.as_view(), name="create"), + path('list/', views.ListPositions.as_view(), name="list"), + path('detail/', views.ViewPosition.as_view(), name="detail"), + path('detail//edit', views.UpdatePosition.as_view(), name="update"), + ] diff --git a/positions/views.py b/positions/views.py new file mode 100644 index 00000000..b1cdb3c6 --- /dev/null +++ b/positions/views.py @@ -0,0 +1,41 @@ +from django.shortcuts import render +from django.views import generic +from django.urls import reverse +import datetime + +from .models import Position +from .forms import UpdateCreatePosition + +from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin + +# Create your views here. + +class CreatePosition(PermissionRequiredMixin, LoginRequiredMixin, generic.CreateView): + model = Position + form_class = UpdateCreatePosition + template_name="form_crispy_cbv.html" + permission_required="positions.add_position" + + def get_success_url(self): + return reverse("accounts:me") + +class UpdatePosition(PermissionRequiredMixin, LoginRequiredMixin, generic.UpdateView): + model = Position + template_name="form_crispy_cbv.html" + permission_required="positions.change_position" + form_class = UpdateCreatePosition + + def get_success_url(self): + return reverse("positions:detail", args=[self.object.id]) + +class ListPositions(PermissionRequiredMixin, LoginRequiredMixin, generic.ListView): + model = Position + queryset = Position.objects.filter(closes__gte=datetime.datetime.now()) + fields = ('name', 'position_start', 'position_end', 'closes') + template_name = 'position_list.html' + permission_required = "positions.apply" + +class ViewPosition(PermissionRequiredMixin, LoginRequiredMixin, generic.DetailView): + model = Position + template_name = "position_detail.html" + permission_required = "positions.apply" diff --git a/site_tmpl/admin.html b/site_tmpl/admin.html index 90f3c2c6..57db7dc1 100644 --- a/site_tmpl/admin.html +++ b/site_tmpl/admin.html @@ -106,6 +106,18 @@

Welcome to the LNL Database

+
+
+ {% if open_positions >= 1 %} + {% permission request.user has 'positions.apply' %} +
+
There {% if open_positions == 1 %}is {% else %}are {% endif %}{{ open_positions }} + open position{{ open_positions|pluralize }} available! + Check out our list + for more information
+
+ {% endpermission %} + {% endif %}
{% endblock %} diff --git a/site_tmpl/admin.nav.html b/site_tmpl/admin.nav.html index a1f48daa..80947aa4 100644 --- a/site_tmpl/admin.nav.html +++ b/site_tmpl/admin.nav.html @@ -123,6 +123,15 @@ {% endpermission %}
  • {% endpermission %} + {% permission request.user has 'positions.apply' or request.user has 'positions.add_position' %} +
  • Open + Positions
  • + {% permission request.user has 'positions.add_position' %} +
  • Add + Position
  • + {% endpermission %} +
  • + {% endpermission %} {% permission request.user has 'events.view_attendance_records' %}
  • Crew Logs
  • {% endpermission %} diff --git a/site_tmpl/position_detail.html b/site_tmpl/position_detail.html new file mode 100644 index 00000000..981eee41 --- /dev/null +++ b/site_tmpl/position_detail.html @@ -0,0 +1,45 @@ +{% extends 'base_admin.html' %} +{% load permissionif %} + +{% block content %} +
    +
    +

    {{ object }}

    +

    Term: {{ object.position_start}} through {{object.position_end}}

    +
    + {% permission user has 'positions.change_position' %} + Edit + {% endpermission %} + {% if object.application_form %} + Apply + {% endif %} +
    + + + + + + + + + + + + + + + + + + + + + + +
    Description{{ object.description }}
    Term Start{{ object.position_start }}
    Term End{{ object.position_end }}
    Reports To + {% if object.reports_to %}{{ object.reports_to }} + {% else %}Exec Board{% endif %} +
    Applications Close{% if object.closes %}{{ object.closes }}{% else %}Doesn't + close{% endif %}
    + +{% endblock %} diff --git a/site_tmpl/position_list.html b/site_tmpl/position_list.html new file mode 100644 index 00000000..18a03385 --- /dev/null +++ b/site_tmpl/position_list.html @@ -0,0 +1,29 @@ +{% extends 'base_admin.html' %} +{% load bootstrap3 %} + +{% block content %} +

    Open positions

    +

    These are positions that the executive board is looking to fill. +Click on a position name to view more details.

    + + + + + + + + + + + {% for pos in object_list %} + + + + + + +{% endfor %} + +
    NameStart dateEnd DateApplication Closes
    {{ pos }}{{ pos.position_start }}{{ pos.position_end }}{% if pos.closes %}{{ pos.closes }}{% else %}Does not + close{% endif %}
    +{% endblock %}