diff --git a/.env_sample b/.env_sample index b759b073..2c706c2a 100644 --- a/.env_sample +++ b/.env_sample @@ -2,4 +2,5 @@ DEVELOPMENT=1 SECRET_KEY="your_secret_key_here" SITE_NAME="localhost" SLACK_ENABLED=True +SLACK_BOT_TOKEN="your_slack_bot_token_here" SUPPORT_EMAIL = 'support@example.com' diff --git a/.gitignore b/.gitignore index 00e863bc..93594ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ venv/ .idea/ MYNOTES.md staticfiles +data/ diff --git a/accounts/admin.py b/accounts/admin.py index 2311c93b..1bb79f15 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -11,6 +11,7 @@ class CustomUserAdmin(BaseUserAdmin): fieldsets = ( (None, {'fields': ('email', 'password')}), ('Personal info', {'fields': ( + 'username', 'full_name', 'slack_display_name', 'user_type', 'current_lms_module', 'organisation')}), ('Permissions', {'fields': ( diff --git a/accounts/forms.py b/accounts/forms.py index bff9b236..b0fefe28 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -24,7 +24,7 @@ class SignupForm(forms.Form): current_lms_module = forms.CharField( widget=forms.Select( choices=LMS_MODULES_CHOICES), - label="Where are you currently in the program?" + label="Where are you currently in the programme?" ) class Meta: @@ -46,6 +46,19 @@ class EditProfileForm(forms.ModelForm): """ Using ModelForm to directly convert the CustomUser model into the EditProfileForm form. """ + full_name = forms.CharField( + max_length=30, + widget=forms.TextInput(attrs={'placeholder': 'Full Name'}), + label='') + slack_display_name = forms.CharField( + max_length=30, + widget=forms.TextInput(attrs={'placeholder': 'Slack Display Name'}), + label='') + current_lms_module = forms.CharField( + widget=forms.Select( + choices=LMS_MODULES_CHOICES), + label="Where are you currently in the programme?" + ) about = forms.CharField(widget=forms.Textarea(), required=False) website_url = forms.CharField(required=False) diff --git a/accounts/lists.py b/accounts/lists.py index 234bc516..39510bcb 100644 --- a/accounts/lists.py +++ b/accounts/lists.py @@ -23,7 +23,7 @@ ('comparative_programming_languages_essentials', 'Comparative Programming Languages Essentials'), ('javascript_essentials', 'Javascript Essentials'), ('interactive_frontend_development', 'Interactive Frontend Development'), - ('python_essentials', 'Python essentials'), + ('python_essentials', 'Python Essentials'), ('practical_python', 'Practical Python'), ('data_centric_development', 'Data Centric Development'), ('backend_development','Backend Development'), diff --git a/accounts/migrations/0012_auto_20210205_1300.py b/accounts/migrations/0012_auto_20210205_1300.py new file mode 100644 index 00000000..90190739 --- /dev/null +++ b/accounts/migrations/0012_auto_20210205_1300.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.3 on 2021-02-05 13:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0011_merge_20210118_1524'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='organisation', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='accounts.organisation'), + ), + ] diff --git a/accounts/migrations/0013_auto_20210215_1505.py b/accounts/migrations/0013_auto_20210215_1505.py new file mode 100644 index 00000000..f531cbf6 --- /dev/null +++ b/accounts/migrations/0013_auto_20210215_1505.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2021-02-15 15:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0012_auto_20210205_1300'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='current_lms_module', + field=models.CharField(choices=[('', 'Select Learning Stage'), ('programme_preliminaries', 'Programme Preliminaries'), ('programming_paradigms', 'Programming Paradigms'), ('html_essentials', 'HTML Essentials'), ('css_essentials', 'CSS Essentials'), ('user_centric_frontend_development', 'User Centric Frontend Development'), ('comparative_programming_languages_essentials', 'Comparative Programming Languages Essentials'), ('javascript_essentials', 'Javascript Essentials'), ('interactive_frontend_development', 'Interactive Frontend Development'), ('python_essentials', 'Python Essentials'), ('practical_python', 'Practical Python'), ('data_centric_development', 'Data Centric Development'), ('backend_development', 'Backend Development'), ('full_stack_frameworks_with_django', 'Full Stack Frameworks with Django'), ('alumni', 'Alumni'), ('staff', 'Staff')], default='', max_length=50), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 10968290..0ccdd765 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -48,8 +48,8 @@ class CustomUser(AbstractUser): organisation = models.ForeignKey( Organisation, on_delete=models.CASCADE, - related_name='user_organisation', - default=Organisation.DEFAULT_PK + related_name='users', + default=1 ) about = models.TextField( diff --git a/accounts/views.py b/accounts/views.py index 72681f21..2b12fd71 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,5 +1,6 @@ from allauth.account.views import SignupView +from django.conf import settings from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required from django.contrib import messages diff --git a/custom_slack_provider/__init__.py b/custom_slack_provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/custom_slack_provider/adapter.py b/custom_slack_provider/adapter.py new file mode 100644 index 00000000..a8811ae7 --- /dev/null +++ b/custom_slack_provider/adapter.py @@ -0,0 +1,91 @@ +from django.conf import settings +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.account.utils import user_email, user_field, user_username +from allauth.utils import ( + deserialize_instance, + email_address_exists, + import_attribute, + serialize_instance, + valid_email_or_none, +) + +import logging + +logger = logging.getLogger(__name__) + + +class CustomSlackSocialAdapter(DefaultSocialAccountAdapter): + def __init__(self, adapter): + self = adapter + + def populate_user(self, + request, + sociallogin, + data): + """ + Hook that can be used to further populate the user instance. + + For convenience, we populate several common fields. + + Note that the user instance being populated represents a + suggested User instance that represents the social user that is + in the process of being logged in. + + The User instance need not be completely valid and conflict + free. For example, verifying whether or not the username + already exists, is not a responsibility. + + Overwriting original function to pull in extra information + """ + username = data.get('username') + full_name = data.get('full_name') + slack_display_name = data.get('slack_display_name') + first_name = data.get('first_name') + last_name = data.get('last_name') + email = data.get('email') + profile_image = data.get('profile_image') + about = data.get('about') + user = sociallogin.user + user_username(user, username or '') + user_email(user, valid_email_or_none(email) or '') + name_parts = (full_name or '').partition(' ') + user_field(user, 'first_name', first_name or name_parts[0]) + user_field(user, 'last_name', last_name or name_parts[2]) + user_field(user, 'full_name', full_name) + user_field(user, 'slack_display_name', slack_display_name) + user_field(user, 'username', username) + user_field(user, 'profile_image', profile_image) + user_field(user, 'about', about) + return user + + def is_auto_signup_allowed(self, request, sociallogin): + # If email is specified, check for duplicate and if so, no auto signup. + auto_signup = app_settings.AUTO_SIGNUP + if auto_signup: + email = user_email(sociallogin.user) + # Let's check if auto_signup is really possible... + if email: + if account_settings.UNIQUE_EMAIL: + if email_address_exists(email): + logger.exception((f'User with email {email} already ' + f'exists.')) + # Oops, another user already has this address. + # We cannot simply connect this social account + # to the existing user. Reason is that the + # email adress may not be verified, meaning, + # the user may be a hacker that has added your + # email address to their account in the hope + # that you fall in their trap. We cannot + # check on 'email_address.verified' either, + # because 'email_address' is not guaranteed to + # be verified. + auto_signup = False + # FIXME: We redirect to signup form -- user will + # see email address conflict only after posting + # whereas we detected it here already. + elif app_settings.EMAIL_REQUIRED: + # Nope, email is required and we don't have it yet... + auto_signup = False + + return auto_signup diff --git a/custom_slack_provider/helpers.py b/custom_slack_provider/helpers.py new file mode 100644 index 00000000..e76b82ed --- /dev/null +++ b/custom_slack_provider/helpers.py @@ -0,0 +1,191 @@ +from django.contrib import messages +from django.forms import ValidationError +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse + +from allauth.account import app_settings as account_settings +from allauth.account.adapter import get_adapter as get_account_adapter +from allauth.account.utils import complete_signup, perform_login, user_username +from allauth.exceptions import ImmediateHttpResponse + +from allauth.socialaccount import app_settings, signals +from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.models import SocialLogin +from allauth.socialaccount.providers.base import AuthError, AuthProcess + + +def _process_signup(request, sociallogin): + auto_signup = get_adapter(request).is_auto_signup_allowed( + request, + sociallogin) + if not auto_signup: + request.session['socialaccount_sociallogin'] = sociallogin.serialize() + url = reverse('socialaccount_signup') + ret = HttpResponseRedirect(url) + else: + # Ok, auto signup it is, at least the e-mail address is ok. + # We still need to check the username though... + if account_settings.USER_MODEL_USERNAME_FIELD: + username = user_username(sociallogin.user) + try: + get_account_adapter(request).clean_username(username) + except ValidationError: + # This username is no good ... + user_username(sociallogin.user, '') + # FIXME: This part contains a lot of duplication of logic + # ("closed" rendering, create user, send email, in active + # etc..) + if not get_adapter(request).is_open_for_signup( + request, + sociallogin): + return render( + request, + "account/signup_closed." + + account_settings.TEMPLATE_EXTENSION) + get_adapter(request).save_user(request, sociallogin, form=None) + ret = complete_social_signup(request, sociallogin) + return ret + + +def _login_social_account(request, sociallogin): + return perform_login(request, sociallogin.user, + email_verification=app_settings.EMAIL_VERIFICATION, + redirect_url=sociallogin.get_redirect_url(request), + signal_kwargs={"sociallogin": sociallogin}) + + +def render_authentication_error(request, + provider_id, + error=AuthError.UNKNOWN, + exception=None, + extra_context=None): + try: + if extra_context is None: + extra_context = {} + get_adapter(request).authentication_error( + request, + provider_id, + error=error, + exception=exception, + extra_context=extra_context) + except ImmediateHttpResponse as e: + return e.response + if error == AuthError.CANCELLED: + return HttpResponseRedirect(reverse('socialaccount_login_cancelled')) + context = { + 'auth_error': { + 'provider': provider_id, + 'code': error, + 'exception': exception + } + } + context.update(extra_context) + return render( + request, + "socialaccount/authentication_error." + + account_settings.TEMPLATE_EXTENSION, + context + ) + + +def _add_social_account(request, sociallogin): + if request.user.is_anonymous: + # This should not happen. Simply redirect to the connections + # view (which has a login required) + return HttpResponseRedirect(reverse('socialaccount_connections')) + level = messages.INFO + message = 'socialaccount/messages/account_connected.txt' + action = None + if sociallogin.is_existing: + if sociallogin.user != request.user: + # Social account of other user. For now, this scenario + # is not supported. Issue is that one cannot simply + # remove the social account from the other user, as + # that may render the account unusable. + level = messages.ERROR + message = 'socialaccount/messages/account_connected_other.txt' + else: + # This account is already connected -- we give the opportunity + # for customized behaviour through use of a signal. + action = 'updated' + message = 'socialaccount/messages/account_connected_updated.txt' + signals.social_account_updated.send( + sender=SocialLogin, + request=request, + sociallogin=sociallogin) + else: + # New account, let's connect + action = 'added' + sociallogin.connect(request, request.user) + signals.social_account_added.send(sender=SocialLogin, + request=request, + sociallogin=sociallogin) + default_next = get_adapter(request).get_connect_redirect_url( + request, + sociallogin.account) + next_url = sociallogin.get_redirect_url(request) or default_next + get_account_adapter(request).add_message( + request, level, message, + message_context={ + 'sociallogin': sociallogin, + 'action': action + } + ) + return HttpResponseRedirect(next_url) + + +def complete_social_login(request, sociallogin): + assert not sociallogin.is_existing + sociallogin.lookup() + try: + get_adapter(request).pre_social_login(request, sociallogin) + signals.pre_social_login.send(sender=SocialLogin, + request=request, + sociallogin=sociallogin) + process = sociallogin.state.get('process') + if process == AuthProcess.REDIRECT: + return _social_login_redirect(request, sociallogin) + elif process == AuthProcess.CONNECT: + return _add_social_account(request, sociallogin) + else: + return _complete_social_login(request, sociallogin) + except ImmediateHttpResponse as e: + return e.response + + +def _social_login_redirect(request, sociallogin): + next_url = sociallogin.get_redirect_url(request) or '/' + return HttpResponseRedirect(next_url) + + +def _complete_social_login(request, sociallogin): + if request.user.is_authenticated: + get_account_adapter(request).logout(request) + if sociallogin.is_existing: + # Login existing user + ret = _login_social_account(request, sociallogin) + signals.social_account_updated.send( + sender=SocialLogin, + request=request, + sociallogin=sociallogin) + else: + # New social user + ret = _process_signup(request, sociallogin) + return ret + + +def complete_social_signup(request, sociallogin): + return complete_signup(request, + sociallogin.user, + app_settings.EMAIL_VERIFICATION, + sociallogin.get_redirect_url(request), + signal_kwargs={'sociallogin': sociallogin}) + + +# TODO: Factor out callable importing functionality +# See: account.utils.user_display +def import_path(path): + modname, _, attr = path.rpartition('.') + m = __import__(modname, fromlist=[attr]) + return getattr(m, attr) diff --git a/custom_slack_provider/models.py b/custom_slack_provider/models.py new file mode 100644 index 00000000..e69de29b diff --git a/custom_slack_provider/provider.py b/custom_slack_provider/provider.py new file mode 100644 index 00000000..9a4d6a45 --- /dev/null +++ b/custom_slack_provider/provider.py @@ -0,0 +1,64 @@ +from allauth.socialaccount.providers.base import ProviderAccount +from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider +from allauth.socialaccount import app_settings + +from custom_slack_provider.adapter import CustomSlackSocialAdapter + + +class SlackAccount(ProviderAccount): + def get_avatar_url(self): + return self.account.extra_data.get('user').get('image_192', None) + + def to_str(self): + dflt = super(SlackAccount, self).to_str() + return '%s (%s)' % ( + self.account.extra_data.get('name', ''), + dflt, + ) + + +class SlackProvider(OAuth2Provider): + id = 'custom_slack_provider' + name = 'Custom Slack Provider' + account_class = SlackAccount + + def sociallogin_from_response(self, request, response): + from allauth.socialaccount.adapter import get_adapter + from allauth.socialaccount.models import SocialLogin, SocialAccount + adapter = get_adapter(request) + adapter = CustomSlackSocialAdapter(adapter) + uid = self.extract_uid(response) + extra_data = self.extract_extra_data(response) + common_fields = self.extract_common_fields(response) + socialaccount = SocialAccount(extra_data=extra_data, + uid=uid, + provider=self.id) + email_addresses = self.extract_email_addresses(response) + self.cleanup_email_addresses(common_fields.get('email'), + email_addresses) + sociallogin = SocialLogin(account=socialaccount, + email_addresses=email_addresses) + user = sociallogin.user = adapter.new_user(request, sociallogin) + user.set_unusable_password() + adapter.populate_user(request, sociallogin, common_fields) + return sociallogin + + def extract_uid(self, data): + return "%s_%s" % (str(data.get('team').get('id')), + str(data.get('user').get('id'))) + + def extract_common_fields(self, data): + user = data.get('user', {}) + return { + 'username': user.get('username'), + 'full_name': user.get('full_name'), + 'slack_display_name': user.get('display_name'), + 'email': user.get('email', None), + 'profile_image': user.get('image_original'), + 'about': user.get('title')} + + def get_default_scope(self): + return ['identify'] + + +provider_classes = [SlackProvider] diff --git a/custom_slack_provider/tests.py b/custom_slack_provider/tests.py new file mode 100644 index 00000000..58bb8875 --- /dev/null +++ b/custom_slack_provider/tests.py @@ -0,0 +1,24 @@ +from allauth.socialaccount.tests import OAuth2TestsMixin +from allauth.tests import MockedResponse, TestCase +from allauth.socialaccount.tests import setup_app + +from .provider import SlackProvider +from django.core.management import call_command + + +class SlackOAuth2Tests(OAuth2TestsMixin, TestCase): + provider_id = SlackProvider.id + provider = SlackProvider + def setUp(self): + call_command('loaddata', 'organisation', verbosity=0) + setup_app(self.provider) + + def get_mocked_response(self): + return MockedResponse(200, """{ + "ok": true, + "url": "https:\\/\\/myteam.slack.com\\/", + "team": {"name": "My Team", "id": "U0G9QF9C6"}, + "user": {"id": "T0G9PQBBK"}, + "team_id": "T12345", + "user_id": "U12345" + }""") # noqa diff --git a/custom_slack_provider/urls.py b/custom_slack_provider/urls.py new file mode 100644 index 00000000..044589fe --- /dev/null +++ b/custom_slack_provider/urls.py @@ -0,0 +1,6 @@ +from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns + +from .provider import SlackProvider + + +urlpatterns = default_urlpatterns(SlackProvider) diff --git a/custom_slack_provider/views.py b/custom_slack_provider/views.py new file mode 100644 index 00000000..491b0cfd --- /dev/null +++ b/custom_slack_provider/views.py @@ -0,0 +1,136 @@ +import logging +import requests + +from accounts.models import CustomUser +from django.core.exceptions import PermissionDenied +from requests import RequestException + +from allauth.socialaccount.providers.oauth2.client import OAuth2Error +from allauth.socialaccount.providers.base import AuthAction, AuthError, AuthProcess +from allauth.socialaccount.providers.base import ProviderException +from allauth.socialaccount.adapter import get_adapter +from .helpers import ( + complete_social_login, + render_authentication_error, +) +from allauth.socialaccount.providers.oauth2.views import ( + OAuth2Adapter, + OAuth2CallbackView, + OAuth2LoginView, + OAuth2View, +) +from allauth.socialaccount import app_settings, signals +from allauth.socialaccount.models import SocialLogin, SocialToken +from allauth.utils import build_absolute_uri, get_request_param + +from django.conf import settings + +from .provider import SlackProvider + +logger = logging.getLogger(__name__) + + +class SlackOAuth2Adapter(OAuth2Adapter): + provider_id = SlackProvider.id + + access_token_url = 'https://slack.com/api/oauth.access' + authorize_url = 'https://slack.com/oauth/authorize' + identity_url = 'https://slack.com/api/users.identity' + user_detail_url = 'https://slack.com/api/users.info' + + def complete_login(self, request, app, token, **kwargs): + extra_data = self.get_data(token.token) + return self.get_provider().sociallogin_from_response(request, + extra_data) + + + def get_data(self, token): + # Verify the user first + resp = requests.get( + self.identity_url, + params={'token': token} + ) + resp = resp.json() + + if not resp.get('ok'): + logger.exception(f'OAuth Exception: {resp.get("error")}') + raise OAuth2Error() + + userid = resp.get('user', {}).get('id') + user_info = requests.get( + self.user_detail_url, + params={'token': settings.SLACK_BOT_TOKEN, 'user': userid} + ) + user_info = user_info.json() + + if not user_info.get('ok'): + logger.exception(f'UserInfo Exception: {user_info.get("error")}') + raise OAuth2Error() + + user_info = user_info.get('user', {}) + display_name = user_info.get('profile', + {}).get('display_name_normalized') + teamid = resp.get('team').get('id') + if not resp.get('user', {}).get('email'): + resp['user']['email'] = user_info.get('email') + resp['user']['display_name'] = display_name + resp['user']['username'] = f'{userid}_{teamid}' + resp['user']['full_name'] = user_info.get('profile', + {}).get('real_name') + resp['user']['first_name'] = user_info.get('profile', + {}).get('first_name') + resp['user']['last_name'] = user_info.get('profile', + {}).get('last_name') + # This key is not present in the response if the user has not + # uploaded an image and the field cannot be None + resp['user']['image_original'] = (user_info.get( + 'profile', {}).get('image_original') or '') + resp['user']['title'] = user_info.get('profile', + {}).get('title') + return resp + + +class CustomOAuth2CallbackView(OAuth2View): + def dispatch(self, request, *args, **kwargs): + if 'error' in request.GET or 'code' not in request.GET: + # Distinguish cancel from error + auth_error = request.GET.get('error', None) + if auth_error == self.adapter.login_cancelled_error: + error = AuthError.CANCELLED + else: + error = AuthError.UNKNOWN + return render_authentication_error( + request, + self.adapter.provider_id, + error=error) + app = self.adapter.get_provider().get_app(self.request) + client = self.get_client(request, app) + try: + access_token = client.get_access_token(request.GET['code']) + token = self.adapter.parse_token(access_token) + token.app = app + login = self.adapter.complete_login(request, + app, + token, + response=access_token) + login.token = token + if self.adapter.supports_state: + login.state = SocialLogin \ + .verify_and_unstash_state( + request, + get_request_param(request, 'state')) + else: + login.state = SocialLogin.unstash_state(request) + return complete_social_login(request, login) + except (PermissionDenied, + OAuth2Error, + RequestException, + ProviderException) as e: + return render_authentication_error( + request, + self.adapter.provider_id, + exception=e) + + +oauth2_login = OAuth2LoginView.adapter_view(SlackOAuth2Adapter) +oauth2_callback = CustomOAuth2CallbackView.adapter_view(SlackOAuth2Adapter) diff --git a/hackathon/fixtures/hackathons.json b/hackathon/fixtures/hackathons.json index b061679c..da034950 100644 --- a/hackathon/fixtures/hackathons.json +++ b/hackathon/fixtures/hackathons.json @@ -14,7 +14,6 @@ "organiser": 12, "organisation": 1, "status": "draft", - "judging_status": "not_yet_started", "hackathon_image": "", "judges": [ 1, @@ -45,7 +44,6 @@ "organiser": 12, "organisation": 1, "status": "deleted", - "judging_status": "closed", "hackathon_image": "", "judges": [ 7, @@ -88,7 +86,6 @@ "organiser": 12, "organisation": 1, "status": "published", - "judging_status": "not_yet_started", "hackathon_image": "", "judges": [ 3, @@ -134,7 +131,6 @@ "organiser": 12, "organisation": 1, "status": "published", - "judging_status": "open", "hackathon_image": "", "judges": [ 5, @@ -181,7 +177,6 @@ "organiser": 1, "organisation": 2, "status": "hack_in_progress", - "judging_status": "not_yet_started", "hackathon_image": "", "judges": [ 1, @@ -226,7 +221,6 @@ "organiser": 1, "organisation": 1, "status": "published", - "judging_status": "not_yet_started", "hackathon_image": "", "judges": [], "participants": [], @@ -248,7 +242,6 @@ "organiser": 1, "organisation": 1, "status": "published", - "judging_status": "not_yet_started", "hackathon_image": "", "judges": [], "participants": [], @@ -276,7 +269,6 @@ "organiser": 1, "organisation": 1, "status": "hack_in_progress", - "judging_status": "not_yet_started", "hackathon_image": "", "judges": [ 3, diff --git a/hackathon/forms.py b/hackathon/forms.py index 2580929d..a686cee3 100644 --- a/hackathon/forms.py +++ b/hackathon/forms.py @@ -4,7 +4,7 @@ from accounts.models import Organisation from .models import Hackathon, HackProject, HackAward,\ HackProjectScoreCategory, HackAwardCategory -from .lists import STATUS_TYPES_CHOICES, JUDGING_STATUS_CHOICES +from .lists import STATUS_TYPES_CHOICES class HackathonForm(forms.ModelForm): """ A form to enable users to add hackathon events via the frontend site. @@ -71,10 +71,9 @@ class HackathonForm(forms.ModelForm): required=True, widget=forms.Select(choices=STATUS_TYPES_CHOICES), ) - judging_status = forms.CharField( - label="Judging Status", + team_size = forms.IntegerField( + label="Team Size", required=True, - widget=forms.Select(choices=JUDGING_STATUS_CHOICES), ) organisation = forms.ModelChoiceField( label="Organisation", @@ -90,8 +89,8 @@ class HackathonForm(forms.ModelForm): class Meta: model = Hackathon fields = ['display_name', 'description', 'theme', 'start_date', - 'end_date', 'status', 'judging_status', 'organisation', - 'score_categories', + 'end_date', 'status', 'organisation', 'score_categories', + 'team_size', ] def __init__(self, *args, **kwargs): diff --git a/hackathon/helpers.py b/hackathon/helpers.py index b8ccba33..64b89d0c 100644 --- a/hackathon/helpers.py +++ b/hackathon/helpers.py @@ -1,4 +1,6 @@ from copy import deepcopy +from dateutil.parser import parse +from datetime import datetime from django.db.models import Count @@ -82,3 +84,12 @@ def count_judges_scores(judges, projects, score_categories): judge_scores[judge.slack_display_name] = ( scores.count() == len(projects) * len(score_categories)) return judge_scores + + +def format_date(date_str): + """ Try parsing your dates with strptime and fallback to dateutil.parser + """ + try: + return datetime.strptime(date_str, '%d/%m/%Y %H:%M') + except ValueError: + return parse(date_str) diff --git a/hackathon/lists.py b/hackathon/lists.py index 0e9cb016..0cf549b5 100644 --- a/hackathon/lists.py +++ b/hackathon/lists.py @@ -12,12 +12,6 @@ ('deleted', 'Deleted'), ) -JUDGING_STATUS_CHOICES = ( - ('not_yet_started', "Hasn't started"), - ('open', "Open"), - ('closed', "Closed"), -) - AWARD_CATEGORIES = ['Best Project', 'Best Project (1st Runners Up)', 'Best Project (2nd Runners Up)', 'Most Innovative Project', 'Best Commercial Application', 'Most Creative Project'] diff --git a/hackathon/migrations/0011_auto_20201020_1948.py b/hackathon/migrations/0011_auto_20201020_1948.py index 3f1dcf3e..c8453b6c 100644 --- a/hackathon/migrations/0011_auto_20201020_1948.py +++ b/hackathon/migrations/0011_auto_20201020_1948.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='hackproject', name='created_by', - field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='hackprojects', to='auth.user'), + field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='hackprojects', to=settings.AUTH_USER_MODEL), preserve_default=False, ), migrations.AddField( diff --git a/hackathon/migrations/0017_auto_20201026_1312.py b/hackathon/migrations/0017_auto_20201026_1312.py index 31857896..c8f44789 100644 --- a/hackathon/migrations/0017_auto_20201026_1312.py +++ b/hackathon/migrations/0017_auto_20201026_1312.py @@ -14,12 +14,7 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='hackathon', - name='organisation', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hackathon_organisation', to='accounts.organisation'), - ), - migrations.AddField( + migrations.AlterField( model_name='hackathon', name='status', field=models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('deleted', 'Deleted')], default='draft', max_length=10), diff --git a/hackathon/migrations/0035_auto_20210205_1300.py b/hackathon/migrations/0035_auto_20210205_1300.py new file mode 100644 index 00000000..3ae1c8a8 --- /dev/null +++ b/hackathon/migrations/0035_auto_20210205_1300.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.3 on 2021-02-05 13:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0012_auto_20210205_1300'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('hackathon', '0034_hackathon_hackathon_image'), + ] + + operations = [ + migrations.AddField( + model_name='hackathon', + name='organisation', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hackathons', to='accounts.organisation'), + ), + migrations.AlterField( + model_name='hackathon', + name='organiser', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='organised_hackathons', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/hackathon/migrations/0036_remove_hackathon_judging_status.py b/hackathon/migrations/0036_remove_hackathon_judging_status.py new file mode 100644 index 00000000..298a7774 --- /dev/null +++ b/hackathon/migrations/0036_remove_hackathon_judging_status.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.3 on 2021-02-08 15:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('hackathon', '0035_auto_20210205_1300'), + ] + + operations = [ + migrations.RemoveField( + model_name='hackathon', + name='judging_status', + ), + ] diff --git a/hackathon/migrations/0037_hackathon_teamsize.py b/hackathon/migrations/0037_hackathon_teamsize.py new file mode 100644 index 00000000..2cbf81ba --- /dev/null +++ b/hackathon/migrations/0037_hackathon_teamsize.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2021-02-09 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hackathon', '0036_remove_hackathon_judging_status'), + ] + + operations = [ + migrations.AddField( + model_name='hackathon', + name='teamsize', + field=models.IntegerField(default=3), + ), + ] diff --git a/hackathon/models.py b/hackathon/models.py index b7f8a457..1d4745eb 100644 --- a/hackathon/models.py +++ b/hackathon/models.py @@ -4,7 +4,7 @@ from accounts.models import CustomUser as User from accounts.models import Organisation -from .lists import STATUS_TYPES_CHOICES, JUDGING_STATUS_CHOICES +from .lists import STATUS_TYPES_CHOICES # Optional fields are ony set to deal with object deletion issues. # If this isn't a problem, they can all be changed to required fields. @@ -34,6 +34,7 @@ class Hackathon(models.Model): theme = models.CharField(max_length=264, blank=False) start_date = models.DateTimeField(blank=False) end_date = models.DateTimeField(blank=False) + teamsize = models.IntegerField(default=3) # Hackathons can have numerous judges and # users could be the judges of more than one Hackathon: Many to Many judges = models.ManyToManyField(User, @@ -55,24 +56,18 @@ class Hackathon(models.Model): null=True, blank=True, on_delete=models.SET_NULL, - related_name="hackathon_organiser") + related_name="organised_hackathons") organisation = models.ForeignKey(Organisation, null=True, blank=True, on_delete=models.SET_NULL, - related_name='hackathon_organisation') + related_name='hackathons') status = models.CharField( max_length=20, blank=False, default='draft', choices=STATUS_TYPES_CHOICES ) - judging_status = models.CharField( - max_length=16, - blank=False, - default='not_yet_started', - choices=JUDGING_STATUS_CHOICES - ) hackathon_image = models.TextField( default="", blank=True, diff --git a/hackathon/templates/hackathon/create-event.html b/hackathon/templates/hackathon/create-event.html index fd807b00..e531052d 100644 --- a/hackathon/templates/hackathon/create-event.html +++ b/hackathon/templates/hackathon/create-event.html @@ -37,10 +37,13 @@
{% endif %}
+ {% if user.is_superuser %}
+ {% endif %}
You are enrolled in this hackathon!
- {% elif hackathon.status == 'hack_in_progress' or hackathon.status == 'judging' or hackathon.start_date|date:"YmdHis" > today|date:"YmdHis" %} + {% elif hackathon.status == 'hack_in_progress' or hackathon.status == 'judging' %} + {% if request.user in hackathon.participants.all %}You are participating in this hackathon
{% else %} +Hackathon currently ongoing.
+ {% endif %} + {% else %}Registrations starting soon!
{% endif %} {% endif %} @@ -49,7 +53,7 @@
- {% endif %}
- Welcome: {{ user.slack_display_name }}
- - - {% else %} -{{ user.slack_display_name }}
- {% endif %} -
+ {% endif %}
+ Welcome: {{ user.slack_display_name }}
+ + {% if not slack_enabled %} + + {% endif %} + {% else %} +{{ user.slack_display_name }}
+ {% endif %} +
+ src="{% static 'img/profiles/profile.png' %}" alt="Profile Image">