Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# SORT
### Self-Assessment of Organisational Readiness Tool


The SORT provides a comprehensive self-assessment framework, enabling organisations to evaluate and strengthen their research capabilities within nursing and
broader health and care practices. By guiding you through forty-four targeted statements, SORT helps assess your current level of research maturity
and the support available for nurses involved in research. Upon completion, your organisation will be equipped to create a tailored improvement plan to better
Expand Down Expand Up @@ -70,6 +69,7 @@ DJANGO_SECRET_KEY=your_secret_key
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=127.0.0.1 localhost
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DJANGO_LOG_LEVEL=DEBUG
```

---
Expand Down
9 changes: 8 additions & 1 deletion SORT/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ def cast_to_boolean(obj: Any) -> bool:
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Plugins
# Plugin apps
"django_bootstrap5",
"django_extensions",
"qr_code",
"crispy_forms",
"crispy_bootstrap5",
"invitations",
# SORT apps
"home",
"survey",
Expand Down Expand Up @@ -260,3 +261,9 @@ def cast_to_boolean(obj: Any) -> bool:
# https://django-crispy-forms.readthedocs.io/en/latest/install.html
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"

# Invitations options
# https://django-invitations.readthedocs.io/en/latest/configuration.html
INVITATIONS_SIGNUP_REDIRECT = "signup"
INVITATIONS_CONFIRMATION_URL_NAME = "member_invite_accept"
INVITATIONS_EMAIL_SUBJECT_PREFIX = "SORT"
14 changes: 14 additions & 0 deletions docs/invitations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Invitations

https://django-invitations.readthedocs.io/

# Usage

## Clear invitations

[Invitations management command](https://django-invitations.readthedocs.io/en/latest/usage.html#management-commands)

```bash
python manage.py clear_expired_invitations
```

8 changes: 8 additions & 0 deletions docs/templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Icons

The [Boxicons](https://boxicons.com/) website has a gallery of available icons that can be implemented using the following HTML code:

```html
<i class='bx bxs-envelope'></i>
```

74 changes: 0 additions & 74 deletions home/forms.py

This file was deleted.

6 changes: 6 additions & 0 deletions home/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .manager_signup import ManagerSignupForm
from .manager_login import ManagerLoginForm
from .search_bar import SearchBarForm
from .user_profile import UserProfileForm

__all__ = ["ManagerSignupForm", "ManagerLoginForm", "SearchBarForm", "UserProfileForm"]
8 changes: 8 additions & 0 deletions home/forms/manager_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import django.forms as forms
from django.contrib.auth.forms import AuthenticationForm


class ManagerLoginForm(AuthenticationForm):
username = forms.EmailField(
label="Email", max_length=60, widget=forms.EmailInput(attrs={"autofocus": True})
)
76 changes: 76 additions & 0 deletions home/forms/manager_signup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""
New manager registration form
"""

import django.contrib.auth.models
import django.forms as forms
from django.contrib.auth.forms import UserCreationForm
from invitations.models import Invitation

from home.services import organisation_service
from home.models import Organisation
from home.constants import ROLE_PROJECT_MANAGER

User = django.contrib.auth.get_user_model()


class ManagerSignupForm(UserCreationForm):
"""
A form to register a new user (a new manager) who was invited by
an existing manager of an organisation.
"""

class Meta:
model = User
fields = ("password1", "password2")

# Secret key for the invitation (hidden form field)
key = forms.CharField(required=False, disabled=True, widget=forms.HiddenInput, label="")

@property
def invitation(self) -> Invitation:
"""
The invitation that the existing manager send to the new user.
"""
return Invitation.objects.get(key=self.data["key"])

@property
def inviter(self) -> User:
"""
The user (manager) who invited this new manager.
"""
return User.objects.get(pk=self.invitation.inviter_id)

@property
def organisation(self) -> Organisation:
"""
The organisation that the new user was invited to join.
"""
organisation = organisation_service.get_user_organisation(user=self.inviter)
if organisation is None:
raise forms.ValidationError("This user is not a manager of an organisation")
return organisation

@property
def email(self) -> str:
"""
The email address of the new user that received the invitation email.
"""
return self.invitation.email

def save(self, commit=True):
user = super().save(commit=False)
user.email = self.email
user.username = user.email
if commit:
user.save()

# Add user to organisation
organisation_service.add_user_to_organisation(
user_to_add=user,
organisation=self.organisation,
user=self.inviter,
role=ROLE_PROJECT_MANAGER,
)

return user
15 changes: 15 additions & 0 deletions home/forms/search_bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import django.forms as forms


class SearchBarForm(forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Search...",
"aria-label": "Search",
"name": "q",
}
),
)
28 changes: 28 additions & 0 deletions home/forms/user_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import django.contrib.auth
import django.forms as forms

User = django.contrib.auth.get_user_model()


class UserProfileForm(forms.ModelForm):
class Meta:
model = User
fields = ["email", "first_name", "last_name"]

def clean_email(self):
email = self.cleaned_data.get("email")

if email == self.instance.email:
return email

if User.objects.exclude(pk=self.instance.pk).filter(email=email).exists():
raise forms.ValidationError("This email is already in use.")
return email

def save(self, commit=True):
user = super().save(commit=False)
if self.cleaned_data.get("password"):
user.set_password(self.cleaned_data["password"])
if commit:
user.save()
return user
3 changes: 3 additions & 0 deletions home/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class User(AbstractBaseUser, PermissionsMixin):
objects = UserManager()

def __str__(self):
# If they didn't enter their name, default to email address
if not self.first_name and not self.last_name:
return self.email
return f"{self.first_name} {self.last_name}"


Expand Down
43 changes: 30 additions & 13 deletions home/services/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def get_user_organisation_ids(self, user: User) -> Set[int]:

@requires_permission("edit", obj_param="organisation")
def update_organisation(
self, user: User, organisation: Organisation, data: Dict
self, user: User, organisation: Organisation, data: Dict
) -> Organisation:
"""Update organisation with provided data"""
for key, value in data.items():
Expand All @@ -84,7 +84,7 @@ def update_organisation(
return organisation

def create_organisation(
self, user: User, name: str, description: str = None
self, user: User, name: str, description: str = None
) -> Organisation:
"""
Create a new organisation, and add the creator to it.
Expand All @@ -99,13 +99,20 @@ def create_organisation(

@requires_permission("edit", obj_param="organisation")
def add_user_to_organisation(
self,
user: User,
user_to_add: User,
organisation: Organisation,
role: str,
self,
user: User,
user_to_add: User,
organisation: Organisation,
role: str,
) -> OrganisationMembership:
"""Add a user to an organisation with specified role"""
"""
Add a user to an organisation with specified role

@param user: user who is adding the user to the organisation
@param user_to_add: user to add
@param organisation: The organisation to add the user to
@param role: The role that the user has in the organisation
"""
if role not in [ROLE_ADMIN, ROLE_PROJECT_MANAGER]:
raise ValueError(
f"Role must be either {ROLE_ADMIN} or {ROLE_PROJECT_MANAGER}"
Expand All @@ -117,15 +124,25 @@ def add_user_to_organisation(

@requires_permission("edit", obj_param="organisation")
def remove_user_from_organisation(
self, user: User, organisation: Organisation, removed_user: User
self, user: User, organisation: Organisation, removed_user: User
) -> None:
"""Remove user from organisation"""
OrganisationMembership.objects.filter(
"""
Remove a user from organisation

@param user: The organisation manager/admin
@param organisation: The organisation to remove the user from
@param removed_user: The user to revoke permissions from
"""
if not self.can_edit(user, organisation):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This permission check wasn't here before.

raise PermissionError(
f"User '{user}' does not have permission to remove users from organisation '{organisation}'")

OrganisationMembership.objects.get(
user=removed_user, organisation=organisation
).delete()

def get_organisation_projects(
self, organisation: Organisation, user: User = None, with_metrics: bool = True
self, organisation: Organisation, user: User = None, with_metrics: bool = True
) -> QuerySet[Project]:
"""Get projects for an organisation with optional metrics"""
if not self.can_view(user, organisation):
Expand All @@ -143,7 +160,7 @@ def get_organisation_projects(

@requires_permission("view", obj_param="organisation")
def get_organisation_members(
self, user: User, organisation: Organisation
self, user: User, organisation: Organisation
) -> QuerySet[OrganisationMembership]:
"""Get all members of an organisation with their roles"""
return OrganisationMembership.objects.filter(
Expand Down
1 change: 0 additions & 1 deletion home/templates/home/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
<div class="text-end mt-1 small-text">
<a href="{% url 'password_reset' %}" class="small">Forgot Password?</a>
</div>
<a href="{% url 'signup' %}">Don't have an account? Sign up</a>
</div>

{% endblock %}
Loading
Loading