├── tests ├── __init__.py ├── backends │ ├── __init__.py │ └── test_model_based_backend.py ├── test_migrations.py ├── utils.py ├── urls.py ├── test_fields.py ├── test_templatetags.py ├── fixtures │ ├── users.json │ └── orgs.json ├── test_signals.py ├── test_utils.py ├── test_mixins.py └── test_forms.py ├── test_custom ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_model_update.py │ └── 0001_initial.py └── models.py ├── example ├── accounts │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── admin.py │ └── models.py ├── conf │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── vendors │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── admin.py │ └── models.py ├── templates │ └── site_base.html └── manage.py ├── test_abstract ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0004_alter_customorganization_slug.py │ ├── 0003_alter_custominvitation_invited_by_and_more.py │ └── 0002_custominvitation.py └── models.py ├── test_accounts ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_model_update.py │ ├── 0004_alter_account_users_and_more.py │ ├── 0003_accountinvitation.py │ └── 0001_initial.py ├── backends.py ├── admin.py ├── urls.py └── models.py ├── test_vendors ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_model_update.py │ ├── 0004_alter_vendor_users_alter_vendorinvitation_invited_by_and_more.py │ ├── 0003_vendorinvitation.py │ └── 0001_initial.py └── models.py ├── src └── organizations │ ├── views │ ├── __init__.py │ ├── default.py │ └── mixins.py │ ├── migrations │ ├── __init__.py │ ├── 0003_field_fix_and_editable.py │ ├── 0002_model_update.py │ ├── 0006_alter_organization_slug.py │ ├── 0005_alter_organization_users_and_more.py │ ├── 0004_organizationinvitation.py │ └── 0001_initial.py │ ├── templatetags │ ├── __init__.py │ └── org_tags.py │ ├── templates │ ├── organizations │ │ ├── email │ │ │ ├── activation_body.html │ │ │ ├── activation_subject.txt │ │ │ ├── reminder_subject.txt │ │ │ ├── invitation_subject.txt │ │ │ ├── modeled_invitation_subject.txt │ │ │ ├── notification_subject.txt │ │ │ ├── modeled_invitation_body.html │ │ │ ├── notification_body.html │ │ │ ├── invitation_body.html │ │ │ └── reminder_body.html │ │ ├── register_success.html │ │ ├── register_form.html │ │ ├── invitation_join.html │ │ ├── organization_form.html │ │ ├── organizationuser_confirm_delete.html │ │ ├── organizationuser_remind.html │ │ ├── organization_users.html │ │ ├── organization_list.html │ │ ├── organizationuser_list.html │ │ ├── organization_confirm_delete.html │ │ ├── login.html │ │ ├── organizationuser_form.html │ │ ├── organization_detail.html │ │ └── organizationuser_detail.html │ └── organizations_base.html │ ├── __init__.py │ ├── locale │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en_GB │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en_US │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ja │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── de │ │ └── LC_MESSAGES │ │ └── django.po │ ├── apps.py │ ├── signals.py │ ├── exceptions.py │ ├── managers.py │ ├── app_settings.py │ ├── base_admin.py │ ├── admin.py │ ├── models.py │ ├── backends │ ├── forms.py │ └── __init__.py │ ├── utils.py │ ├── fields.py │ ├── urls.py │ └── forms.py ├── .coveragerc ├── setup.py ├── MANIFEST.in ├── docs ├── reference │ ├── models.rst │ ├── index.rst │ ├── forms.rst │ ├── mixins.rst │ ├── managers.rst │ ├── settings.rst │ ├── views.rst │ └── backends.rst ├── signals.rst ├── index.rst ├── usage.rst ├── getting_started.rst └── Makefile ├── .gitignore ├── .readthedocs.yaml ├── .git-blame-ignore-revs ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ └── release.yml ├── tox.ini ├── setup.cfg ├── LICENSE ├── AUTHORS.rst ├── Makefile ├── pyproject.toml ├── conftest.py ├── manage.py ├── noxfile.py └── CONTRIBUTING.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_custom/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/conf/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/vendors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_abstract/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_vendors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/vendors/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/organizations/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_abstract/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_custom/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_vendors/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/backends/__init__.py: -------------------------------------------------------------------------------- 1 | """ """ 2 | -------------------------------------------------------------------------------- /example/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/organizations/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/organizations/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/activation_body.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/activation_subject.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/reminder_subject.txt: -------------------------------------------------------------------------------- 1 | Just a reminder 2 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations_base.html: -------------------------------------------------------------------------------- 1 | {% block content %}{% endblock %} 2 | -------------------------------------------------------------------------------- /example/templates/site_base.html: -------------------------------------------------------------------------------- 1 | 2 | {% block content %}{% endblock %} 3 | 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = organizations 4 | omit = organizations/migrations/* 5 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/register_success.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% trans "Thanks!" %} 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | See setup.cfg for packaging settings 3 | """ 4 | 5 | from setuptools import setup 6 | 7 | setup() 8 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/invitation_subject.txt: -------------------------------------------------------------------------------- 1 | {% spaceless %}You've been invited!{% endspaceless %} 2 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/modeled_invitation_subject.txt: -------------------------------------------------------------------------------- 1 | {% spaceless %}You've been invited!{% endspaceless %} 2 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/notification_subject.txt: -------------------------------------------------------------------------------- 1 | {% spaceless %}You've been added to an organization{% endspaceless %} 2 | -------------------------------------------------------------------------------- /src/organizations/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | __author__ = "Ben Lopatin" 4 | __email__ = "ben@benlopatin.com" 5 | __version__ = "2.5.0" 6 | -------------------------------------------------------------------------------- /src/organizations/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennylope/django-organizations/HEAD/src/organizations/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/organizations/locale/en_GB/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennylope/django-organizations/HEAD/src/organizations/locale/en_GB/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/organizations/locale/en_US/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennylope/django-organizations/HEAD/src/organizations/locale/en_US/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py 2 | include setup.cfg 3 | include README.rst 4 | include MANIFEST.in 5 | include HISTORY.rst 6 | include LICENSE 7 | recursive-include src/organizations/templates * 8 | -------------------------------------------------------------------------------- /docs/reference/models.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Models 3 | ====== 4 | 5 | `Organization` 6 | ============== 7 | 8 | `OrganizationUser` 9 | ================== 10 | 11 | `OrganizationOwner` 12 | =================== 13 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/register_form.html: -------------------------------------------------------------------------------- 1 |
{% csrf_token %} 2 | {{ form }} 3 | 4 |
5 | -------------------------------------------------------------------------------- /test_custom/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from organizations.models import Organization 3 | 4 | 5 | class Team(Organization): 6 | sport = models.CharField(max_length=100, blank=True, null=True) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | dist/ 4 | *.egg-info 5 | docs/_build 6 | *.sqlite 7 | .tox 8 | .nox 9 | .env 10 | 11 | .coverage 12 | htmlcov/ 13 | .cache 14 | .idea/ 15 | .pytest_cache/ 16 | .mypy_cache/ 17 | stubs/ 18 | -------------------------------------------------------------------------------- /src/organizations/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrganizationsConfig(AppConfig): 5 | name = "organizations" 6 | verbose_name = "Organizations" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /src/organizations/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | user_added = django.dispatch.Signal() 4 | user_removed = django.dispatch.Signal() 5 | invitation_accepted = django.dispatch.Signal() 6 | owner_changed = django.dispatch.Signal() 7 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/invitation_join.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% trans "Would you like to join?" %} 3 |
{% csrf_token %} 4 | 5 |
6 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | models 11 | managers 12 | mixins 13 | views 14 | forms 15 | settings 16 | backends 17 | -------------------------------------------------------------------------------- /example/vendors/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Vendor 4 | from .models import VendorOwner 5 | from .models import VendorUser 6 | 7 | admin.site.register(Vendor) 8 | admin.site.register(VendorUser) 9 | admin.site.register(VendorOwner) 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - docs 17 | -------------------------------------------------------------------------------- /example/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Account 4 | from .models import AccountOwner 5 | from .models import AccountUser 6 | 7 | admin.site.register(Account) 8 | admin.site.register(AccountUser) 9 | admin.site.register(AccountOwner) 10 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organization_form.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% block content %} 3 |

{{ organization }}

4 |
{% csrf_token %} 5 | {{ form }} 6 | 7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /test_accounts/backends.py: -------------------------------------------------------------------------------- 1 | from organizations.backends.defaults import RegistrationBackend 2 | from test_accounts.models import Account 3 | 4 | 5 | class AccountRegistration(RegistrationBackend): 6 | def __init__(self, namespace=None): 7 | super().__init__(org_model=Account, namespace=namespace) 8 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conf.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organizationuser_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% block content %} 3 |

{{ organization }}

4 |
{% csrf_token %} 5 | {{ form }} 6 | 7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organizationuser_remind.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% block content %} 3 |

{{ organization }}

4 |
{% csrf_token %} 5 | {{ form }} 6 | 7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 99a314fda6e4ffd52f07917c8d1dee91ebb3aa69 2 | e5a50b54a4e783df861fe8fd18439cc5e21a9481 3 | db974d9214b491144b43be35d8e806d1df15fada 4 | e9206fb9b470718d3b4a88717ab2ce19de02aac9 5 | 99f26965fb610e1336a186ae2891af06f82d7a20 6 | b6b50b4e85333b2cb981c4e1f74cd0d6ba9ac324 7 | 8e673b591e105bfd606d1488c7421369a3aaeb14 8 | -------------------------------------------------------------------------------- /docs/reference/forms.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Forms 3 | ===== 4 | 5 | `OrganizationForm` 6 | ================== 7 | 8 | 9 | `OrganizationUserForm` 10 | ====================== 11 | 12 | 13 | `OrganizationUserAddForm` 14 | ========================= 15 | 16 | 17 | `OrganizationAddForm` 18 | ===================== 19 | 20 | 21 | `SignUpForm` 22 | ============ 23 | -------------------------------------------------------------------------------- /docs/reference/mixins.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Mixins 3 | ====== 4 | 5 | 6 | `OrganizationMixin` 7 | =================== 8 | 9 | `OrganizationUserMixin` 10 | ======================= 11 | 12 | `MembershipRequiredMixin` 13 | ========================= 14 | 15 | `AdminRequiredMixin` 16 | ==================== 17 | 18 | `OwnerRequiredMixin` 19 | ==================== 20 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests that migrations are not missing 3 | """ 4 | 5 | from django.core.management import call_command 6 | 7 | import pytest 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_no_missing_migrations(): 12 | """Verify that no changes are detected in the migrations.""" 13 | call_command("makemigrations", check=True, dry_run=True) 14 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/modeled_invitation_body.html: -------------------------------------------------------------------------------- 1 | You've been invited to join {{ organization|safe }} on {{ domain.name }} by {{ sender.first_name|safe }} {{ sender.last_name|safe }}. 2 | 3 | Follow this link to create your user account. 4 | 5 | http://{{ domain.domain }}{{ invitation }} 6 | 7 | If you are unsure about this link please contact the sender. 8 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organization_users.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 10 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/notification_body.html: -------------------------------------------------------------------------------- 1 | You've been added to the organization {{ organization|safe }} on {{ domain.name }} by {{ sender.full_name|safe }}.` 2 | 3 | Follow the link below to view this organization. 4 | 5 | http://{{ domain.domain }}{% url "organization_detail" organization.pk %} 6 | 7 | If you are unsure about this link please contact the sender. 8 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organization_list.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{% trans "organizations" %}

5 | 6 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/invitation_body.html: -------------------------------------------------------------------------------- 1 | You've been invited to join {{ organization|safe }} on {{ domain.name }} by {{ sender.first_name|safe }} {{ sender.last_name|safe }}. 2 | 3 | Follow this link to create your user account. 4 | 5 | http://{{ domain.domain }}{% url "invitations_register" user.pk token %} 6 | 7 | If you are unsure about this link please contact the sender. 8 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/email/reminder_body.html: -------------------------------------------------------------------------------- 1 | You've been invited to join {{ organization|safe }} on {{ domain.name }} by {{ sender.first_name|safe }} {{ sender.last_name|safe }}. 2 | 3 | Follow this link to create your user account. 4 | 5 | http://{{ domain.domain }}{% url "invitations_register" user.pk token %} 6 | 7 | If you are unsure about this link please contact the sender. 8 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organizationuser_list.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% load org_tags %} 4 | {% block content %} 5 |

{{ organization }}'s Members

6 | 9 | {% organization_users organization %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organization_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{{ organization }}

5 |

{% trans "Are you sure you want to delete this organization?" %}

6 |
{% csrf_token %} 7 | {{ form }} 8 | 9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /test_accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from test_accounts.models import Account 4 | from test_accounts.models import AccountInvitation 5 | from test_accounts.models import AccountOwner 6 | from test_accounts.models import AccountUser 7 | 8 | admin.site.register(Account) 9 | admin.site.register(AccountInvitation) 10 | admin.site.register(AccountUser) 11 | admin.site.register(AccountOwner) 12 | -------------------------------------------------------------------------------- /src/organizations/exceptions.py: -------------------------------------------------------------------------------- 1 | class OwnershipRequired(Exception): 2 | """ 3 | Exception to raise if the owner is being removed before the 4 | organization. 5 | """ 6 | 7 | pass 8 | 9 | 10 | class OrganizationMismatch(Exception): 11 | """ 12 | Exception to raise if an organization user from a different 13 | organization is assigned to be an organization's owner. 14 | """ 15 | 16 | pass 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "docs|node_modules|migrations|.git|.tox" 2 | default_stages: [ commit ] 3 | fail_fast: true 4 | 5 | repos: 6 | 7 | - repo: https://github.com/charliermarsh/ruff-pre-commit 8 | rev: "v0.1.6" 9 | hooks: 10 | - id: ruff-format 11 | - id: ruff 12 | 13 | - repo: https://github.com/pycqa/isort 14 | rev: 5.12.0 15 | hooks: 16 | - id: isort 17 | args: 18 | - --settings-path=pyproject.toml 19 | -------------------------------------------------------------------------------- /test_accounts/migrations/0002_model_update.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("test_accounts", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="accountuser", 12 | name="user_type", 13 | field=models.CharField(default="", max_length=1), 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /src/organizations/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class OrgManager(models.Manager): 5 | def get_for_user(self, user): 6 | return self.get_queryset().filter(users=user) 7 | 8 | 9 | class ActiveOrgManager(OrgManager): 10 | """ 11 | A more useful extension of the default manager which returns querysets 12 | including only active organizations 13 | """ 14 | 15 | def get_queryset(self): 16 | return super().get_queryset().filter(is_active=True) 17 | -------------------------------------------------------------------------------- /example/conf/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.contrib import admin 3 | from django.urls import path 4 | 5 | from organizations.backends import invitation_backend 6 | from organizations.backends import registration_backend 7 | 8 | urlpatterns = [ 9 | path("admin/", admin.site.urls), 10 | path("organizations/", include("organizations.urls")), 11 | path("invite/", include(invitation_backend().get_urls())), 12 | path("register/", include(registration_backend().get_urls())), 13 | ] 14 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/login.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

Log in to your dashboard

5 |
6 |
{% csrf_token %} 7 | {{ form }} 8 | 9 |
10 |

{% trans "Forgotten your password?" %} {% trans "Reset it" %}.

11 |
12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organizationuser_form.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 | {% if profile %} 5 |

{% trans "Update your profile" %}

6 | {% else %} 7 |

{{ organization_user }} @ {{ organization }}

8 | {% endif %} 9 | {% if user == organization_user.user %}{% trans "This is you" %}!{% endif %} 10 |
{% csrf_token %} 11 | {{ form }} 12 | 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /docs/reference/managers.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Managers 3 | ======== 4 | 5 | `OrgManager` 6 | ============ 7 | 8 | Base manager class for the `Organization` model. 9 | 10 | .. method:: OrgManager.get_for_user(user) 11 | 12 | Returns a QuerySet of Organizations that the given user is a member of. 13 | 14 | `ActiveOrgManager` 15 | ================== 16 | 17 | This manager extends the `OrgManager` class by defining a base queryset 18 | including only active Organizations. This manager is accessible from the 19 | `active` attribute on the `Organization` class. 20 | -------------------------------------------------------------------------------- /src/organizations/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import User 3 | 4 | from organizations.utils import model_field_attr 5 | 6 | ORGS_INVITATION_BACKEND = getattr( 7 | settings, "INVITATION_BACKEND", "organizations.backends.defaults.InvitationBackend" 8 | ) 9 | 10 | ORGS_REGISTRATION_BACKEND = getattr( 11 | settings, 12 | "REGISTRATION_BACKEND", 13 | "organizations.backends.defaults.RegistrationBackend", 14 | ) 15 | 16 | ORGS_EMAIL_LENGTH = model_field_attr(User, "email", "max_length") 17 | -------------------------------------------------------------------------------- /test_accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.urls import path 3 | from django.contrib import admin 4 | 5 | from organizations.backends.modeled import ModelInvitation 6 | from test_accounts.models import Account 7 | 8 | admin.autodiscover() 9 | 10 | app_name = "test_accounts" 11 | 12 | urlpatterns = [ 13 | path( 14 | "register/", 15 | include( 16 | ModelInvitation(org_model=Account, namespace="invitations").urls, 17 | namespace="account_invitations", 18 | ), 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /test_custom/migrations/0002_model_update.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("test_custom", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AlterModelOptions( 11 | name="team", 12 | options={ 13 | "ordering": ["name"], 14 | "verbose_name": "organization", 15 | "verbose_name_plural": "organizations", 16 | }, 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /src/organizations/templatetags/org_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.inclusion_tag("organizations/organization_users.html", takes_context=True) 7 | def organization_users(context, org): 8 | context.update({"organization_users": org.organization_users.all()}) 9 | return context 10 | 11 | 12 | @register.filter 13 | def is_admin(org, user): 14 | return org.is_admin(user) 15 | 16 | 17 | @register.filter 18 | def is_owner(org, user): 19 | return org.owner.organization_user.user == user 20 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organization_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% load org_tags %} 4 | {% block content %} 5 |

{{ organization }}

6 | 11 | {% organization_users organization %} 12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/' 7 | - 'tests/' 8 | 9 | jobs: 10 | flake8: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Setup Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.11.5 18 | - name: Install dependencies 19 | run: pip install .[linting] 20 | - name: Run flake8 21 | run: | 22 | ruff check . 23 | ruff format --check . 24 | isort src/organizations --check --diff 25 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # from django.conf import settings 2 | # from django.contrib.auth import SESSION_KEY, BACKEND_SESSION_KEY 3 | from django.contrib.auth.models import AnonymousUser 4 | 5 | # from django.utils.importlib import import_module 6 | 7 | 8 | def request_factory_login(factory, user=None, method="request", **kwargs): 9 | """Based on this gist: https://gist.github.com/964472""" 10 | # engine = import_module(settings.SESSION_ENGINE) 11 | request = getattr(factory, method)(**kwargs) 12 | # request.session = engine.SessionStore() 13 | # request.session[SESSION_KEY] = user.pk 14 | request.user = user or AnonymousUser() 15 | return request 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /docs/signals.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Signals 3 | ======= 4 | 5 | 6 | `user_added`: dispatched from the `user_add` method on an organization. The sender is the organization, and the user is the provided arg. 7 | `user_removed`: dispatched from the `user_remove` method on an organization. The sender is the organization, and the user is the provided arg. 8 | `invitation_accepted`: dispatched from the ModeledInvitation backend. The sender is the ModelInvitation instance 9 | `owner_changed`: dispatched from the `change_owner` method on an organization instance. The sender is the organization, and the `old` owner's organization user and the `new` owner's organization user are the providing args. 10 | -------------------------------------------------------------------------------- /src/organizations/base_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | 4 | class BaseOwnerInline(admin.StackedInline): 5 | raw_id_fields = ("organization_user",) 6 | 7 | 8 | class BaseOrganizationAdmin(admin.ModelAdmin): 9 | list_display = ["name", "is_active"] 10 | prepopulated_fields = {"slug": ("name",)} 11 | search_fields = ["name"] 12 | list_filter = ("is_active",) 13 | 14 | 15 | class BaseOrganizationUserAdmin(admin.ModelAdmin): 16 | list_display = ["user", "organization", "is_admin"] 17 | raw_id_fields = ("user", "organization") 18 | 19 | 20 | class BaseOrganizationOwnerAdmin(admin.ModelAdmin): 21 | raw_id_fields = ("organization_user", "organization") 22 | -------------------------------------------------------------------------------- /example/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from organizations.abstract import AbstractOrganization 4 | from organizations.abstract import AbstractOrganizationInvitation 5 | from organizations.abstract import AbstractOrganizationOwner 6 | from organizations.abstract import AbstractOrganizationUser 7 | 8 | 9 | class Account(AbstractOrganization): 10 | monthly_subscription = models.IntegerField(default=1000) 11 | 12 | 13 | class AccountUser(AbstractOrganizationUser): 14 | user_type = models.CharField(max_length=1, default="") 15 | 16 | 17 | class AccountOwner(AbstractOrganizationOwner): 18 | pass 19 | 20 | 21 | class AccountInvitation(AbstractOrganizationInvitation): 22 | pass 23 | -------------------------------------------------------------------------------- /src/organizations/migrations/0003_field_fix_and_editable.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | import organizations.fields 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("organizations", "0002_model_update")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="organization", 12 | name="slug", 13 | field=organizations.fields.SlugField( 14 | editable=True, 15 | help_text="The name in all lowercase, suitable for URL identification", 16 | max_length=200, 17 | populate_from="name", 18 | unique=True, 19 | ), 20 | ) 21 | ] 22 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 3 | from django.urls import include 4 | from django.urls import path 5 | 6 | from organizations.backends import invitation_backend 7 | from organizations.backends import registration_backend 8 | 9 | admin.autodiscover() 10 | 11 | 12 | urlpatterns = [ 13 | path("admin/", admin.site.urls), 14 | path("organizations/", include("organizations.urls")), 15 | path("invite/", include(invitation_backend().get_urls())), 16 | path("register/", include(registration_backend().get_urls())), 17 | path("accounts/", include("test_accounts.urls", namespace="test_accounts")), 18 | ] + staticfiles_urlpatterns() 19 | -------------------------------------------------------------------------------- /src/organizations/templates/organizations/organizationuser_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "organizations_base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{{ organization_user }} @ {{ organization }}

5 | {% if user == organization_user.user %}{% trans "This is you" %}!{% endif %} 6 | 10 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /src/organizations/migrations/0002_model_update.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | import organizations.fields 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("organizations", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="organization", 12 | name="slug", 13 | field=organizations.fields.SlugField( 14 | blank=True, 15 | editable=False, 16 | help_text="The name in all lowercase, suitable for URL identification", 17 | max_length=200, 18 | populate_from=("name",), 19 | unique=True, 20 | ), 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for configurable fields 3 | """ 4 | 5 | import importlib 6 | 7 | from django.core.exceptions import ImproperlyConfigured 8 | 9 | import pytest 10 | 11 | from organizations import fields 12 | 13 | 14 | def test_misconfigured_autoslug_cannot_import(settings): 15 | settings.ORGS_SLUGFIELD = "not.AModel" 16 | with pytest.raises(ImproperlyConfigured): 17 | importlib.reload(fields) 18 | 19 | 20 | def test_misconfigured_autoslug_incorrect_class(settings): 21 | settings.ORGS_SLUGFIELD = "autoslug.AutoSlug" 22 | with pytest.raises(ImproperlyConfigured): 23 | importlib.reload(fields) 24 | 25 | 26 | def test_misconfigured_autoslug_bad_notation(settings): 27 | settings.ORGS_SLUGFIELD = "autoslug.AutoSlugField" 28 | importlib.reload(fields) 29 | -------------------------------------------------------------------------------- /test_vendors/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.db import models 3 | from organizations.base import OrganizationBase 4 | from organizations.base import OrganizationUserBase 5 | from organizations.base import OrganizationOwnerBase 6 | from organizations.base import OrganizationInvitationBase 7 | 8 | 9 | class Vendor(OrganizationBase): 10 | street_address = models.CharField(max_length=100, default="") 11 | city = models.CharField(max_length=100, default="") 12 | 13 | 14 | class VendorUser(OrganizationUserBase): 15 | user_type = models.CharField(max_length=1, default="") 16 | permissions = models.ManyToManyField(Permission, blank=True) 17 | 18 | 19 | class VendorOwner(OrganizationOwnerBase): 20 | pass 21 | 22 | 23 | class VendorInvitation(OrganizationInvitationBase): 24 | pass 25 | -------------------------------------------------------------------------------- /src/organizations/migrations/0006_alter_organization_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-10-13 02:45 2 | 3 | from django.db import migrations 4 | 5 | import organizations.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("organizations", "0005_alter_organization_users_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="organization", 16 | name="slug", 17 | field=organizations.fields.SlugField( 18 | blank=True, 19 | editable=False, 20 | help_text="The name in all lowercase, suitable for URL identification", 21 | max_length=200, 22 | populate_from="name", 23 | unique=True, 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /test_vendors/migrations/0002_model_update.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("test_vendors", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="vendor", 12 | name="city", 13 | field=models.CharField(default="", max_length=100), 14 | ), 15 | migrations.AlterField( 16 | model_name="vendor", 17 | name="street_address", 18 | field=models.CharField(default="", max_length=100), 19 | ), 20 | migrations.AlterField( 21 | model_name="vendoruser", 22 | name="user_type", 23 | field=models.CharField(default="", max_length=1), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /test_abstract/migrations/0004_alter_customorganization_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-10-13 02:45 2 | 3 | from django.db import migrations 4 | import organizations.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("test_abstract", "0003_alter_custominvitation_invited_by_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="customorganization", 15 | name="slug", 16 | field=organizations.fields.SlugField( 17 | blank=True, 18 | editable=False, 19 | help_text="The name in all lowercase, suitable for URL identification", 20 | max_length=200, 21 | populate_from="name", 22 | unique=True, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /test_abstract/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.db import models 3 | 4 | from organizations.abstract import AbstractOrganization 5 | from organizations.abstract import AbstractOrganizationInvitation 6 | from organizations.abstract import AbstractOrganizationOwner 7 | from organizations.abstract import AbstractOrganizationUser 8 | 9 | 10 | class CustomOrganization(AbstractOrganization): 11 | street_address = models.CharField(max_length=100, default="") 12 | city = models.CharField(max_length=100, default="") 13 | 14 | 15 | class CustomUser(AbstractOrganizationUser): 16 | user_type = models.CharField(max_length=1, default="") 17 | permissions = models.ManyToManyField(Permission, blank=True) 18 | 19 | 20 | class CustomOwner(AbstractOrganizationOwner): 21 | pass 22 | 23 | 24 | class CustomInvitation(AbstractOrganizationInvitation): 25 | pass 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | release: 7 | types: [created] 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.x' 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install hatch 25 | 26 | - name: Build and publish distribution 27 | if: (github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')) || github.event_name == 'release' 28 | env: 29 | HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} 30 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} 31 | run: | 32 | hatch build 33 | hatch publish 34 | -------------------------------------------------------------------------------- /example/conf/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for conf project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | 17 | import os 18 | 19 | from django.core.wsgi import get_wsgi_application 20 | 21 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conf.settings") 22 | application = get_wsgi_application() 23 | -------------------------------------------------------------------------------- /test_accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | from organizations.base import OrganizationBase 5 | from organizations.base import OrganizationInvitationBase 6 | from organizations.base import OrganizationOwnerBase 7 | from organizations.base import OrganizationUserBase 8 | 9 | 10 | class Account(OrganizationBase): 11 | monthly_subscription = models.IntegerField(default=1000) 12 | 13 | 14 | class AccountUser(OrganizationUserBase): 15 | user_type = models.CharField(max_length=1, default="") 16 | 17 | 18 | class AccountOwner(OrganizationOwnerBase): 19 | pass 20 | 21 | 22 | class AccountInvitation(OrganizationInvitationBase): 23 | def get_absolute_url(self): 24 | """Returns the invitation URL""" 25 | return reverse( 26 | "test_accounts:account_invitations:invitations_register", 27 | kwargs={"guid": str(self.guid)}, 28 | ) 29 | -------------------------------------------------------------------------------- /src/organizations/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from organizations import models 4 | from organizations.base_admin import BaseOrganizationAdmin 5 | from organizations.base_admin import BaseOrganizationOwnerAdmin 6 | from organizations.base_admin import BaseOrganizationUserAdmin 7 | from organizations.base_admin import BaseOwnerInline 8 | 9 | 10 | class OwnerInline(BaseOwnerInline): 11 | model = models.OrganizationOwner 12 | 13 | 14 | @admin.register(models.Organization) 15 | class OrganizationAdmin(BaseOrganizationAdmin): 16 | inlines = [OwnerInline] 17 | 18 | 19 | @admin.register(models.OrganizationUser) 20 | class OrganizationUserAdmin(BaseOrganizationUserAdmin): 21 | pass 22 | 23 | 24 | @admin.register(models.OrganizationOwner) 25 | class OrganizationOwnerAdmin(BaseOrganizationOwnerAdmin): 26 | pass 27 | 28 | 29 | @admin.register(models.OrganizationInvitation) 30 | class OrganizationInvitationAdmin(admin.ModelAdmin): 31 | pass 32 | -------------------------------------------------------------------------------- /docs/reference/settings.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Settings 3 | ======== 4 | 5 | .. attribute:: settings.INVITATION_BACKEND 6 | 7 | The full dotted path to the invitation backend. Defaults to:: 8 | 9 | INVITATION_BACKEND = 'organizations.backends.defaults.InvitationBackend' 10 | 11 | .. attribute:: settings.REGISTRATION_BACKEND 12 | 13 | The full dotted path to the regisration backend. Defaults to:: 14 | 15 | REGISTRATION_BACKEND = 'organizations.backends.defaults.RegistrationBackend' 16 | 17 | .. attribute:: settings.AUTH_USER_MODEL 18 | 19 | This setting is introduced in Django 1.5 to support swappable user models. 20 | The defined here will be used by django-organizations as the related user 21 | class to Organizations. 22 | 23 | Though the swappable user model functionality is absent, this setting can be 24 | used in Django 1.4 with django-organizations to relate a custom user model. 25 | If undefined it will default to:: 26 | 27 | AUTH_USER_MODEL = 'auth.User' 28 | -------------------------------------------------------------------------------- /example/vendors/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.db import models 3 | 4 | from organizations.abstract import AbstractOrganization 5 | from organizations.abstract import AbstractOrganizationInvitation 6 | from organizations.abstract import AbstractOrganizationOwner 7 | from organizations.abstract import AbstractOrganizationUser 8 | 9 | 10 | class Vendor(AbstractOrganization): 11 | street_address = models.CharField(max_length=100, default="") 12 | city = models.CharField(max_length=100, default="") 13 | account = models.ForeignKey( 14 | "accounts.Account", on_delete=models.CASCADE, related_name="vendors" 15 | ) 16 | 17 | 18 | class VendorUser(AbstractOrganizationUser): 19 | user_type = models.CharField(max_length=1, default="") 20 | permissions = models.ManyToManyField(Permission, blank=True) 21 | 22 | 23 | class VendorOwner(AbstractOrganizationOwner): 24 | pass 25 | 26 | 27 | class VendorInvitation(AbstractOrganizationInvitation): 28 | pass 29 | -------------------------------------------------------------------------------- /src/organizations/models.py: -------------------------------------------------------------------------------- 1 | from organizations.abstract import AbstractOrganization 2 | from organizations.abstract import AbstractOrganizationInvitation 3 | from organizations.abstract import AbstractOrganizationOwner 4 | from organizations.abstract import AbstractOrganizationUser 5 | 6 | 7 | class Organization(AbstractOrganization): 8 | """ 9 | Default Organization model. 10 | """ 11 | 12 | class Meta(AbstractOrganization.Meta): 13 | abstract = False 14 | 15 | 16 | class OrganizationUser(AbstractOrganizationUser): 17 | """ 18 | Default OrganizationUser model. 19 | """ 20 | 21 | class Meta(AbstractOrganizationUser.Meta): 22 | abstract = False 23 | 24 | 25 | class OrganizationOwner(AbstractOrganizationOwner): 26 | """ 27 | Default OrganizationOwner model. 28 | """ 29 | 30 | class Meta(AbstractOrganizationOwner.Meta): 31 | abstract = False 32 | 33 | 34 | class OrganizationInvitation(AbstractOrganizationInvitation): 35 | class Meta(AbstractOrganizationInvitation.Meta): 36 | abstract = False 37 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========================================= 2 | django-organizations: multi-user accounts 3 | ========================================= 4 | 5 | django-organizations is an application that provides group account 6 | functionality for Django, allowing user access and rights to be consolidated 7 | into group accounts. 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | getting_started 15 | usage 16 | custom_usage 17 | cookbook 18 | signals 19 | reference/index 20 | 21 | 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`modindex` 27 | * :ref:`search` 28 | 29 | 30 | Sponsors 31 | ======== 32 | 33 | `Muster `_ is building precision advocacy software to impact policy through grassroots action. 34 | 35 | .. image:: https://www.muster.com/hs-fs/hubfs/muster_logo-2.png?width=600&name=muster_logo-2.png 36 | :target: https://www.muster.com/home?utm_source=readthedocs&campaign=opensource 37 | :width: 400 38 | :alt: Alternative text 39 | 40 | -------------------------------------------------------------------------------- /src/organizations/backends/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.forms import UserCreationForm 3 | 4 | 5 | class UserRegistrationForm(UserCreationForm): 6 | """ 7 | Form class for completing a user's registration and activating the 8 | User. 9 | 10 | The class operates on a user model which is assumed to have the required 11 | fields of a BaseUserModel 12 | """ 13 | 14 | # TODO(bennylope): Remove this entirely and replace with base class 15 | 16 | 17 | def org_registration_form(org_model): 18 | """ 19 | Generates a registration ModelForm for the given organization model class 20 | """ 21 | 22 | class OrganizationRegistrationForm(forms.ModelForm): 23 | """Form class for creating new organizations owned by new users.""" 24 | 25 | email = forms.EmailField() 26 | 27 | class Meta: 28 | model = org_model 29 | exclude = ("is_active", "users") 30 | 31 | # def save(self, *args, **kwargs): 32 | # self.instance.is_active = False 33 | # super().save(*args, **kwargs) 34 | 35 | return OrganizationRegistrationForm 36 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8, 4 | py{39,310}-django{32}, 5 | py{39}-django{42}, 6 | py{310,311,312}-django{42} 7 | py{310,311,312,313}-django{52} 8 | 9 | [gh-actions] 10 | python = 11 | 3.9: py39 12 | 3.10: py310 13 | 3.11: py311 14 | 3.12: py312 15 | 3.13: py313 16 | 17 | [build-system] 18 | build-backend = "hatchling.build" 19 | requires = ["hatchling>=0.22", "hatch-vcs>=0.2"] 20 | 21 | [testenv] 22 | package = wheel 23 | setenv = 24 | PYTHONPATH = {toxinidir}:{toxinidir}/organizations 25 | commands = pytest {posargs} --cov=organizations 26 | basepython = 27 | py39: python3.9 28 | py310: python3.10 29 | py311: python3.11 30 | py312: python3.12 31 | py313: python3.13 32 | deps = 33 | hatch>=1.7.0 34 | django32: Django>=3.2,<4 35 | django42: Django>=4.2,<5 36 | django52: Django>=5.2,<6 37 | extras = tests 38 | 39 | [testenv:flake8] 40 | basepython=python3 41 | deps= 42 | flake8==3.12.7 43 | commands= 44 | flake8 src/organizations tests 45 | 46 | [flake8] 47 | ignore = E126,E128,W503 48 | max-line-length = 120 49 | exclude = migrations,.ropeproject 50 | max-complexity = 10 51 | 52 | -------------------------------------------------------------------------------- /test_custom/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2017-12-05 00:17 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [("organizations", "0001_initial")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Team", 15 | fields=[ 16 | ( 17 | "organization_ptr", 18 | models.OneToOneField( 19 | auto_created=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | parent_link=True, 22 | primary_key=True, 23 | serialize=False, 24 | to="organizations.Organization", 25 | ), 26 | ), 27 | ("sport", models.CharField(blank=True, max_length=100, null=True)), 28 | ], 29 | options={ 30 | "verbose_name": "organization", 31 | "verbose_name_plural": "organizations", 32 | "ordering": ["name"], 33 | "abstract": False, 34 | }, 35 | bases=("organizations.organization",), 36 | ) 37 | ] 38 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-organizations 3 | version = attr: organizations.__version__ 4 | author = Ben Lopatin 5 | author_email = ben@benlopatin.com 6 | url = https://github.com/bennylope/django-organizations/ 7 | description = Group accounts for Django 8 | long_description = file: README.rst 9 | license = BSD License 10 | platforms = 11 | OS Independent 12 | classifiers = 13 | Framework :: Django 14 | Environment :: Web Environment 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: BSD License 17 | Operating System :: OS Independent 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3.8 20 | Programming Language :: Python :: 3.9 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: Implementation :: CPython 24 | Development Status :: 5 - Production/Stable 25 | 26 | [options] 27 | zip_safe = False 28 | include_package_data = True 29 | packages = find: 30 | package_dir= 31 | =src 32 | install_requires = 33 | Django>=3.2.0 34 | django-extensions>=2.0.8 35 | python_requires = >=3.8 36 | 37 | [options.packages.find] 38 | where=src 39 | 40 | [bdist_wheel] 41 | universal = 1 42 | 43 | [build-system] 44 | requires = 45 | setuptools >= "40.9.0" 46 | wheel 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2019, Ben Lopatin and contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. Redistributions in binary 9 | form must reproduce the above copyright notice, this list of conditions and the 10 | following disclaimer in the documentation and/or other materials provided with 11 | the distribution 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Primary author: 2 | 3 | * `Ben Lopatin `_ 4 | 5 | Contributors: 6 | 7 | * `Sebastian Annies `_ 8 | * `Paul Backhouse `_ 9 | * `Phil McMahon `_ 10 | * `Aaron Krill `_ 11 | * `Mauricio de Abreu Antunes `_ 12 | * `Omer Katz `_ 13 | * `Andrew Velis `_ 14 | * `Tom Davis `_ 15 | * `Nicolas Noirbent `_ 16 | * `Samuel Bishop `_ 17 | * `Eric Amador `_ 18 | * `Jon Miller `_ 19 | * `Robert Christopher `_ 20 | * `Basil Shubin `_ 21 | * `Federico Capoano `_ 22 | * `Justin Mayer `_ 23 | * `Alan Zhu `_ 24 | * `Samuel Spencer `_ 25 | * `Seb Vetter `_ 26 | * `KimSia Sim `_ 27 | * `Dan Moore `_ 28 | * `Sky `_ 29 | 30 | If your name is missing as a contributor that's my oversight, let me know at 31 | ben@benlopatin.com 32 | -------------------------------------------------------------------------------- /src/organizations/views/default.py: -------------------------------------------------------------------------------- 1 | from organizations.models import Organization 2 | from organizations.views.base import ViewFactory 3 | from organizations.views.mixins import AdminRequiredMixin 4 | from organizations.views.mixins import MembershipRequiredMixin 5 | from organizations.views.mixins import OwnerRequiredMixin 6 | 7 | bases = ViewFactory(Organization) 8 | 9 | 10 | class OrganizationList(bases.OrganizationList): 11 | pass 12 | 13 | 14 | class OrganizationCreate(bases.OrganizationCreate): 15 | """ 16 | Allows any user to create a new organization. 17 | """ 18 | 19 | pass 20 | 21 | 22 | class OrganizationDetail(MembershipRequiredMixin, bases.OrganizationDetail): 23 | pass 24 | 25 | 26 | class OrganizationUpdate(AdminRequiredMixin, bases.OrganizationUpdate): 27 | pass 28 | 29 | 30 | class OrganizationDelete(OwnerRequiredMixin, bases.OrganizationDelete): 31 | pass 32 | 33 | 34 | class OrganizationUserList(MembershipRequiredMixin, bases.OrganizationUserList): 35 | pass 36 | 37 | 38 | class OrganizationUserDetail(AdminRequiredMixin, bases.OrganizationUserDetail): 39 | pass 40 | 41 | 42 | class OrganizationUserUpdate(AdminRequiredMixin, bases.OrganizationUserUpdate): 43 | pass 44 | 45 | 46 | class OrganizationUserCreate(AdminRequiredMixin, bases.OrganizationUserCreate): 47 | pass 48 | 49 | 50 | class OrganizationUserRemind(AdminRequiredMixin, bases.OrganizationUserRemind): 51 | pass 52 | 53 | 54 | class OrganizationUserDelete(AdminRequiredMixin, bases.OrganizationUserDelete): 55 | pass 56 | -------------------------------------------------------------------------------- /src/organizations/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from typing import Optional # noqa 3 | from typing import Text # noqa 4 | 5 | from organizations.app_settings import ORGS_INVITATION_BACKEND 6 | from organizations.app_settings import ORGS_REGISTRATION_BACKEND 7 | from organizations.backends.defaults import BaseBackend # noqa 8 | 9 | 10 | def invitation_backend(backend=None, namespace=None): 11 | # type: (Optional[Text], Optional[Text]) -> BaseBackend 12 | """ 13 | Returns a specified invitation backend 14 | 15 | Args: 16 | backend: dotted path to the invitation backend class 17 | namespace: URL namespace to use 18 | 19 | Returns: 20 | an instance of an InvitationBackend 21 | 22 | """ 23 | backend = backend or ORGS_INVITATION_BACKEND 24 | class_module, class_name = backend.rsplit(".", 1) 25 | mod = import_module(class_module) 26 | return getattr(mod, class_name)(namespace=namespace) 27 | 28 | 29 | def registration_backend(backend=None, namespace=None): 30 | # type: (Optional[Text], Optional[Text]) -> BaseBackend 31 | """ 32 | Returns a specified registration backend 33 | 34 | Args: 35 | backend: dotted path to the registration backend class 36 | namespace: URL namespace to use 37 | 38 | Returns: 39 | an instance of an RegistrationBackend 40 | 41 | """ 42 | backend = backend or ORGS_REGISTRATION_BACKEND 43 | class_module, class_name = backend.rsplit(".", 1) 44 | mod = import_module(class_module) 45 | return getattr(mod, class_name)(namespace=namespace) 46 | -------------------------------------------------------------------------------- /docs/reference/views.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Views 3 | ===== 4 | 5 | Base views 6 | ========== 7 | 8 | `BaseOrganizationList` 9 | ---------------------- 10 | 11 | `BaseOrganizationDetail` 12 | ------------------------ 13 | 14 | `BaseOrganizationCreate` 15 | ------------------------ 16 | 17 | `BaseOrganizationUpdate` 18 | ------------------------ 19 | 20 | `BaseOrganizationDelete` 21 | ------------------------ 22 | 23 | `BaseOrganizationUserList` 24 | -------------------------- 25 | 26 | `BaseOrganizationUserDetail` 27 | ---------------------------- 28 | 29 | `BaseOrganizationUserCreate` 30 | ---------------------------- 31 | 32 | `BaseOrganizationUserRemind` 33 | ---------------------------- 34 | 35 | `BaseOrganizationUserUpdate` 36 | ---------------------------- 37 | 38 | `BaseOrganizationUserDelete` 39 | ---------------------------- 40 | 41 | Controlled views 42 | ================ 43 | 44 | `OrganizationCreate` 45 | -------------------- 46 | 47 | `OrganizationDetail` 48 | -------------------- 49 | 50 | `OrganizationUpdate` 51 | -------------------- 52 | 53 | `OrganizationDelete` 54 | -------------------- 55 | 56 | `OrganizationUserList` 57 | ---------------------- 58 | 59 | `OrganizationUserDetail` 60 | ------------------------ 61 | 62 | `OrganizationUserUpdate` 63 | ------------------------ 64 | 65 | `OrganizationUserCreate` 66 | ------------------------ 67 | 68 | `OrganizationUserRemind` 69 | ------------------------ 70 | 71 | `OrganizationUserDelete` 72 | ------------------------ 73 | 74 | Misc. views 75 | =========== 76 | 77 | `OrganizationSignup` 78 | -------------------- 79 | 80 | `signup_success` 81 | ---------------- 82 | 83 | -------------------------------------------------------------------------------- /test_accounts/migrations/0004_alter_account_users_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-30 17:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("auth", "0012_alter_user_first_name_max_length"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("test_accounts", "0003_accountinvitation"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="account", 18 | name="users", 19 | field=models.ManyToManyField( 20 | related_name="%(app_label)s_%(class)s", 21 | through="test_accounts.AccountUser", 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="accountinvitation", 27 | name="invited_by", 28 | field=models.ForeignKey( 29 | on_delete=django.db.models.deletion.CASCADE, 30 | related_name="%(app_label)s_%(class)s_sent_invitations", 31 | to=settings.AUTH_USER_MODEL, 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="accountinvitation", 36 | name="invitee", 37 | field=models.ForeignKey( 38 | blank=True, 39 | null=True, 40 | on_delete=django.db.models.deletion.CASCADE, 41 | related_name="%(app_label)s_%(class)s_invitations", 42 | to=settings.AUTH_USER_MODEL, 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name="accountuser", 47 | name="user", 48 | field=models.ForeignKey( 49 | on_delete=django.db.models.deletion.CASCADE, 50 | related_name="%(app_label)s_%(class)s", 51 | to=settings.AUTH_USER_MODEL, 52 | ), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /test_abstract/migrations/0003_alter_custominvitation_invited_by_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-30 17:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("auth", "0012_alter_user_first_name_max_length"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("test_abstract", "0002_custominvitation"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="custominvitation", 18 | name="invited_by", 19 | field=models.ForeignKey( 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="%(app_label)s_%(class)s_sent_invitations", 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="custominvitation", 27 | name="invitee", 28 | field=models.ForeignKey( 29 | blank=True, 30 | null=True, 31 | on_delete=django.db.models.deletion.CASCADE, 32 | related_name="%(app_label)s_%(class)s_invitations", 33 | to=settings.AUTH_USER_MODEL, 34 | ), 35 | ), 36 | migrations.AlterField( 37 | model_name="customorganization", 38 | name="users", 39 | field=models.ManyToManyField( 40 | related_name="%(app_label)s_%(class)s", 41 | through="test_abstract.CustomUser", 42 | to=settings.AUTH_USER_MODEL, 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name="customuser", 47 | name="user", 48 | field=models.ForeignKey( 49 | on_delete=django.db.models.deletion.CASCADE, 50 | related_name="%(app_label)s_%(class)s", 51 | to=settings.AUTH_USER_MODEL, 52 | ), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /test_vendors/migrations/0004_alter_vendor_users_alter_vendorinvitation_invited_by_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-30 17:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("auth", "0012_alter_user_first_name_max_length"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("test_vendors", "0003_vendorinvitation"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="vendor", 18 | name="users", 19 | field=models.ManyToManyField( 20 | related_name="%(app_label)s_%(class)s", 21 | through="test_vendors.VendorUser", 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="vendorinvitation", 27 | name="invited_by", 28 | field=models.ForeignKey( 29 | on_delete=django.db.models.deletion.CASCADE, 30 | related_name="%(app_label)s_%(class)s_sent_invitations", 31 | to=settings.AUTH_USER_MODEL, 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="vendorinvitation", 36 | name="invitee", 37 | field=models.ForeignKey( 38 | blank=True, 39 | null=True, 40 | on_delete=django.db.models.deletion.CASCADE, 41 | related_name="%(app_label)s_%(class)s_invitations", 42 | to=settings.AUTH_USER_MODEL, 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name="vendoruser", 47 | name="user", 48 | field=models.ForeignKey( 49 | on_delete=django.db.models.deletion.CASCADE, 50 | related_name="%(app_label)s_%(class)s", 51 | to=settings.AUTH_USER_MODEL, 52 | ), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /src/organizations/migrations/0005_alter_organization_users_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-30 17:00 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("auth", "0012_alter_user_first_name_max_length"), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ("organizations", "0004_organizationinvitation"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="organization", 19 | name="users", 20 | field=models.ManyToManyField( 21 | related_name="%(app_label)s_%(class)s", 22 | through="organizations.OrganizationUser", 23 | to=settings.AUTH_USER_MODEL, 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="organizationinvitation", 28 | name="invited_by", 29 | field=models.ForeignKey( 30 | on_delete=django.db.models.deletion.CASCADE, 31 | related_name="%(app_label)s_%(class)s_sent_invitations", 32 | to=settings.AUTH_USER_MODEL, 33 | ), 34 | ), 35 | migrations.AlterField( 36 | model_name="organizationinvitation", 37 | name="invitee", 38 | field=models.ForeignKey( 39 | blank=True, 40 | null=True, 41 | on_delete=django.db.models.deletion.CASCADE, 42 | related_name="%(app_label)s_%(class)s_invitations", 43 | to=settings.AUTH_USER_MODEL, 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name="organizationuser", 48 | name="user", 49 | field=models.ForeignKey( 50 | on_delete=django.db.models.deletion.CASCADE, 51 | related_name="%(app_label)s_%(class)s", 52 | to=settings.AUTH_USER_MODEL, 53 | ), 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.template import Context 3 | from django.template import Template 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | 7 | from organizations.models import Organization 8 | 9 | 10 | @override_settings(USE_TZ=True) 11 | class TestTagsAndFilters(TestCase): 12 | fixtures = ["users.json", "orgs.json"] 13 | 14 | def setUp(self): 15 | self.kurt = User.objects.get(username="kurt") 16 | self.dave = User.objects.get(username="dave") 17 | self.nirvana = Organization.objects.get(name="Nirvana") 18 | self.foo = Organization.objects.get(name="Foo Fighters") 19 | self.context = {} 20 | 21 | def test_organization_users_tag(self): 22 | self.context = {"organization": self.nirvana} 23 | out = Template( 24 | "{% load org_tags %}" "{% organization_users organization %}" 25 | ).render(Context(self.context)) 26 | self.assertIn("Kurt", out) 27 | self.assertIn("Dave", out) 28 | 29 | def test_is_owner_org_filter(self): 30 | self.context = {"organization": self.nirvana, "user": self.kurt} 31 | out = Template( 32 | "{% load org_tags %}" 33 | "{% if organization|is_owner:user %}" 34 | "Is Owner" 35 | "{% endif %}" 36 | ).render(Context(self.context)) 37 | self.assertEqual(out, "Is Owner") 38 | 39 | def test_is_admin_org_filter(self): 40 | self.context = {"organization": self.foo, "user": self.dave} 41 | out = Template( 42 | "{% load org_tags %}" 43 | "{% if organization|is_admin:user %}" 44 | "Is Admin" 45 | "{% endif %}" 46 | ).render(Context(self.context)) 47 | self.assertEqual(out, "Is Admin") 48 | 49 | def test_is_not_admin_org_filter(self): 50 | self.context = {"organization": self.nirvana, "user": self.dave} 51 | out = Template( 52 | "{% load org_tags %}" 53 | "{% if not organization|is_admin:user %}" 54 | "Is Not Admin" 55 | "{% endif %}" 56 | ).render(Context(self.context)) 57 | self.assertEqual(out, "Is Not Admin") 58 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean 2 | 3 | TEST_FLAGS=--verbose 4 | COVER_FLAGS=--cov=organizations 5 | 6 | help: 7 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 8 | 9 | install: ## install all requirements including for testing 10 | pip install -r requirements-dev.txt 11 | 12 | install-quiet: ## same as install but pipes all output to /dev/null 13 | pip install -r requirements-dev.txt > /dev/null 14 | 15 | clean: clean-build clean-pyc clean-test-all ## remove all artifacts 16 | 17 | clean-build: ## remove build artifacts 18 | @rm -rf build/ 19 | @rm -rf dist/ 20 | @rm -rf *.egg-info 21 | 22 | clean-pyc: ## remove Python file artifacts 23 | -@find . -name '*.pyc' -follow -print0 | xargs -0 rm -f &> /dev/null 24 | -@find . -name '*.pyo' -follow -print0 | xargs -0 rm -f &> /dev/null 25 | -@find . -name '__pycache__' -type d -follow -print0 | xargs -0 rm -rf &> /dev/null 26 | 27 | clean-test: ## remove test and coverage artifacts 28 | rm -rf .coverage coverage* 29 | rm -rf tests/.coverage test/coverage* 30 | rm -rf htmlcov/ 31 | 32 | clean-test-all: clean-test ## remove all test-related artifacts including tox 33 | rm -rf .tox/ 34 | 35 | format: ## format the code with Black 36 | black organizations tests example test_abstract test_accounts test_custom test_vendors 37 | 38 | lint: ## check style with flake8 39 | flake8 organizations 40 | 41 | test: ## run tests quickly with the default Python 42 | pytest ${TEST_FLAGS} 43 | 44 | test-coverage: clean-test ## run tests with coverage report 45 | -pytest ${COVER_FLAGS} ${TEST_FLAGS} 46 | @exit_code=$? 47 | @-coverage html 48 | @exit ${exit_code} 49 | 50 | test-all: ## run tests on every Python version with tox 51 | tox 52 | 53 | check: clean-build clean-pyc clean-test lint test-coverage ## run all necessary steps to check validity of project 54 | 55 | build: clean ## Create distribution files for release 56 | # pytest -k test_no_missing_migrations 57 | python setup.py sdist bdist_wheel 58 | 59 | release: build ## Create distribution files and publish to PyPI 60 | python setup.py check -r -s 61 | twine upload dist/* 62 | 63 | sdist: clean ## Create source distribution only 64 | python setup.py sdist 65 | ls -l dist 66 | 67 | docs: ## Build and open docs 68 | $(MAKE) -C docs clean 69 | $(MAKE) -C docs html 70 | open docs/_build/html/index.html 71 | -------------------------------------------------------------------------------- /test_vendors/migrations/0003_vendorinvitation.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-06-27 00:55 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("test_vendors", "0002_model_update"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="VendorInvitation", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("guid", models.UUIDField(editable=False)), 28 | ( 29 | "invitee_identifier", 30 | models.CharField( 31 | help_text=( 32 | "The contact identifier for the invitee, email, " 33 | "phone number, social media handle, etc." 34 | ), 35 | max_length=1000, 36 | ), 37 | ), 38 | ( 39 | "invited_by", 40 | models.ForeignKey( 41 | on_delete=django.db.models.deletion.CASCADE, 42 | related_name="test_vendors_vendorinvitation_sent_invitations", 43 | to=settings.AUTH_USER_MODEL, 44 | ), 45 | ), 46 | ( 47 | "invitee", 48 | models.ForeignKey( 49 | blank=True, 50 | null=True, 51 | on_delete=django.db.models.deletion.CASCADE, 52 | related_name="test_vendors_vendorinvitation_invitations", 53 | to=settings.AUTH_USER_MODEL, 54 | ), 55 | ), 56 | ( 57 | "organization", 58 | models.ForeignKey( 59 | on_delete=django.db.models.deletion.CASCADE, 60 | related_name="organization_invites", 61 | to="test_vendors.Vendor", 62 | ), 63 | ), 64 | ], 65 | options={"abstract": False}, 66 | ) 67 | ] 68 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-organizations" 3 | authors = [ 4 | { name="Ben Lopatin", email="ben@benlopatin.com" }, 5 | ] 6 | description = "Group accounts for Django" 7 | readme = "README.rst" 8 | requires-python = ">=3.9" 9 | license = {text = "BSD License"} 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Environment :: Web Environment", 13 | "Framework :: Django", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: BSD License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Programming Language :: Python :: Implementation :: CPython", 24 | ] 25 | dependencies = [ 26 | "Django>=3.2", 27 | "django-extensions>=2.0.8", 28 | ] 29 | dynamic = ["version"] 30 | 31 | [project.urls] 32 | "Source" = "https://github.com/bennylope/django-organizations" 33 | "Issues" = "https://github.com/bennylope/django-organizations/issues" 34 | "Documentation" = "https://django-organizations.readthedocs.io/en/latest/" 35 | 36 | [project.optional-dependencies] 37 | tests = [ 38 | "pytest>=6.0", 39 | "coverage", 40 | "pytest-django>=3.0.0", 41 | "pytest-cov>=2.4.0", 42 | # Required to test default models 43 | "django-extensions>=2.0.8", 44 | "django-autoslug>=1.9.8,", 45 | # Required for mocking signals 46 | "mock-django==0.6.9", 47 | ] 48 | docs = [ 49 | "Sphinx==7.2.6", 50 | "furo==2023.9.10", 51 | ] 52 | linting = [ 53 | "ruff", 54 | "isort", 55 | ] 56 | dev = [ 57 | "django-organizations[tests,linting]", 58 | "tox", 59 | "pre-commit", 60 | ] 61 | 62 | [build-system] 63 | requires = ["hatchling"] 64 | build-backend = "hatchling.build" 65 | 66 | [tool.hatch.build] 67 | sources = ["src"] 68 | directory = "dist/" 69 | 70 | [tool.hatch.version] 71 | path = "src/organizations/__init__.py" 72 | 73 | [tool.hatch.build.targets.wheel] 74 | only-include = ["src/organizations"] 75 | packages = ["src/organizations"] 76 | 77 | [tool.black] 78 | target-version = ["py311"] 79 | 80 | [tool.isort] 81 | line_length = 88 82 | force_single_line = true 83 | known_future_library = "future" 84 | known_django = "django" 85 | known_first_party = ["organizations"] 86 | sections = ["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 87 | default_section = "THIRDPARTY" 88 | skip_glob = ["**/__init__.py", "*/__init__.py"] 89 | -------------------------------------------------------------------------------- /test_accounts/migrations/0003_accountinvitation.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-06-27 00:55 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("test_accounts", "0002_model_update"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="AccountInvitation", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("guid", models.UUIDField(editable=False)), 28 | ( 29 | "invitee_identifier", 30 | models.CharField( 31 | help_text=( 32 | "The contact identifier for the invitee, email, " 33 | "phone number, social media handle, etc." 34 | ), 35 | max_length=1000, 36 | ), 37 | ), 38 | ( 39 | "invited_by", 40 | models.ForeignKey( 41 | on_delete=django.db.models.deletion.CASCADE, 42 | related_name="test_accounts_accountinvitation_sent_invitations", 43 | to=settings.AUTH_USER_MODEL, 44 | ), 45 | ), 46 | ( 47 | "invitee", 48 | models.ForeignKey( 49 | blank=True, 50 | null=True, 51 | on_delete=django.db.models.deletion.CASCADE, 52 | related_name="test_accounts_accountinvitation_invitations", 53 | to=settings.AUTH_USER_MODEL, 54 | ), 55 | ), 56 | ( 57 | "organization", 58 | models.ForeignKey( 59 | on_delete=django.db.models.deletion.CASCADE, 60 | related_name="organization_invites", 61 | to="test_accounts.Account", 62 | ), 63 | ), 64 | ], 65 | options={"abstract": False}, 66 | ) 67 | ] 68 | -------------------------------------------------------------------------------- /tests/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "username": "dave", 7 | "first_name": "Dave", 8 | "last_name": "Grohl", 9 | "is_active": true, 10 | "is_superuser": true, 11 | "is_staff": true, 12 | "last_login": "2012-05-24T03:31:00Z", 13 | "groups": [], 14 | "user_permissions": [], 15 | "password": "pbkdf2_sha256$10000$rAnsaHYYl9cK$Ongm4KhPqyRwjgPKUv5kxCznkj1cGh0N68lVlX75Ylk=", 16 | "email": "dave@foo.com", 17 | "date_joined": "2012-05-22T01:06:59Z" 18 | } 19 | }, 20 | { 21 | "pk": 2, 22 | "model": "auth.user", 23 | "fields": { 24 | "username": "krist", 25 | "first_name": "Krist", 26 | "last_name": "Novoselic", 27 | "is_active": true, 28 | "is_superuser": false, 29 | "is_staff": false, 30 | "last_login": "2012-05-26T01:23:06Z", 31 | "groups": [], 32 | "user_permissions": [], 33 | "password": "pbkdf2_sha256$10000$BywHke8M7Vuu$2+fAbb+HY7xSGGEtiuiWH5ve7v/LVKMStwgz2r9iwEg=", 34 | "email": "krist@nirvana.com", 35 | "date_joined": "2012-05-26T01:23:06Z" 36 | } 37 | }, 38 | { 39 | "pk": 3, 40 | "model": "auth.user", 41 | "fields": { 42 | "username": "kurt", 43 | "first_name": "Kurt", 44 | "last_name": "Cobain", 45 | "is_active": true, 46 | "is_superuser": false, 47 | "is_staff": false, 48 | "last_login": "2012-05-26T01:23:42Z", 49 | "groups": [], 50 | "user_permissions": [], 51 | "password": "pbkdf2_sha256$10000$lNs67Hs6WlUb$kr+pZ/7V6+YfL0s/2ZBrk1ZeP59zKYUKmm4CtwrzYTo=", 52 | "email": "kurt@nirvana.com", 53 | "date_joined": "2012-05-26T01:23:42Z" 54 | } 55 | }, 56 | { 57 | "pk": 4, 58 | "model": "auth.user", 59 | "fields": { 60 | "username": "duder", 61 | "first_name": "Some", 62 | "last_name": "Tester", 63 | "is_active": true, 64 | "is_superuser": false, 65 | "is_staff": false, 66 | "last_login": "2012-05-26T01:23:06Z", 67 | "groups": [], 68 | "user_permissions": [], 69 | "password": "pbkdf2_sha256$10000$BywHke8M7Vuu$2+fAbb+HY7xSGGEtiuiWH5ve7v/LVKMStwgz2r9iwEg=", 70 | "email": "duder@testing.com", 71 | "date_joined": "2012-05-26T01:23:06Z" 72 | } 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration file for py.test 3 | """ 4 | 5 | import django 6 | 7 | import pytest 8 | 9 | 10 | def pytest_configure(): 11 | from django.conf import settings 12 | 13 | settings.configure( 14 | DEBUG=True, 15 | USE_TZ=True, 16 | DATABASES={ 17 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.sqlite3"} 18 | }, 19 | INSTALLED_APPS=[ 20 | "django.contrib.auth", 21 | "django.contrib.contenttypes", 22 | "django.contrib.sites", 23 | "django.contrib.admin", 24 | "django.contrib.sessions", 25 | # The ordering here, the apps using the organization base models 26 | # first and *then* the organizations app itself is an implicit test 27 | # that the organizations app need not be installed in order to use 28 | # its base models. 29 | "test_accounts", 30 | "test_abstract", 31 | "test_vendors", 32 | "organizations", 33 | "test_custom", 34 | ], 35 | MIDDLEWARE=[ 36 | "django.middleware.security.SecurityMiddleware", 37 | "django.contrib.sessions.middleware.SessionMiddleware", 38 | "django.middleware.common.CommonMiddleware", 39 | "django.middleware.csrf.CsrfViewMiddleware", 40 | "django.contrib.auth.middleware.AuthenticationMiddleware", 41 | "django.contrib.messages.middleware.MessageMiddleware", 42 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 43 | ], 44 | TEMPLATES=[ 45 | { 46 | "BACKEND": "django.template.backends.django.DjangoTemplates", 47 | "APP_DIRS": True, 48 | "OPTIONS": { 49 | "context_processors": [ 50 | "django.contrib.auth.context_processors.auth", 51 | "django.template.context_processors.debug", 52 | "django.template.context_processors.i18n", 53 | "django.template.context_processors.media", 54 | "django.template.context_processors.static", 55 | "django.template.context_processors.request", 56 | "django.contrib.messages.context_processors.messages", 57 | ], 58 | "debug": True, 59 | }, 60 | } 61 | ], 62 | SITE_ID=1, 63 | FIXTURE_DIRS=["tests/fixtures"], 64 | ORGS_SLUGFIELD="django_extensions.db.fields.AutoSlugField", 65 | ROOT_URLCONF="tests.urls", 66 | STATIC_URL="/static/", 67 | SECRET_KEY="ThisIsHorriblyInsecure", 68 | ) 69 | django.setup() 70 | 71 | 72 | @pytest.fixture(autouse=True) 73 | def enable_db_access_for_all_tests(db): 74 | pass 75 | -------------------------------------------------------------------------------- /src/organizations/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-05-14 20:09-0400\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: organizations/backends/defaults.py:68 21 | msgid "You must define a form_class" 22 | msgstr "" 23 | 24 | #: organizations/backends/defaults.py:110 25 | #: organizations/backends/defaults.py:112 26 | msgid "Your URL may have expired." 27 | msgstr "" 28 | 29 | #: organizations/base.py:162 organizations/forms.py:158 30 | msgid "The name of the organization" 31 | msgstr "" 32 | 33 | #: organizations/forms.py:59 34 | msgid "Only the organization owner can change ownerhip" 35 | msgstr "" 36 | 37 | #: organizations/forms.py:73 38 | msgid "The organization owner must be an admin" 39 | msgstr "" 40 | 41 | #: organizations/forms.py:100 42 | msgid "This email address has been used multiple times." 43 | msgstr "" 44 | 45 | #: organizations/forms.py:114 46 | msgid "There is already an organization member with this email address!" 47 | msgstr "" 48 | 49 | #: organizations/forms.py:125 50 | msgid "The email address for the account owner" 51 | msgstr "" 52 | 53 | #: organizations/forms.py:160 organizations/models.py:83 54 | msgid "The name in all lowercase, suitable for URL identification" 55 | msgstr "" 56 | 57 | #: organizations/mixins.py:94 58 | msgid "Wrong organization" 59 | msgstr "" 60 | 61 | #: organizations/mixins.py:109 62 | msgid "Sorry, admins only" 63 | msgstr "" 64 | 65 | #: organizations/mixins.py:124 66 | msgid "You are not the organization owner" 67 | msgstr "" 68 | 69 | #: organizations/models.py:86 70 | msgid "organization" 71 | msgstr "" 72 | 73 | #: organizations/models.py:87 74 | msgid "organizations" 75 | msgstr "" 76 | 77 | #: organizations/models.py:183 78 | msgid "organization user" 79 | msgstr "" 80 | 81 | #: organizations/models.py:184 82 | msgid "organization users" 83 | msgstr "" 84 | 85 | #: organizations/models.py:200 86 | msgid "" 87 | "Cannot delete organization owner before organization or transferring " 88 | "ownership." 89 | msgstr "" 90 | 91 | #: organizations/models.py:215 92 | msgid "organization owner" 93 | msgstr "" 94 | 95 | #: organizations/models.py:216 96 | msgid "organization owners" 97 | msgstr "" 98 | 99 | #: organizations/views.py:133 100 | msgid "Already active" 101 | msgstr "" 102 | -------------------------------------------------------------------------------- /src/organizations/locale/en_US/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-05-14 20:09-0400\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: organizations/backends/defaults.py:68 21 | msgid "You must define a form_class" 22 | msgstr "" 23 | 24 | #: organizations/backends/defaults.py:110 25 | #: organizations/backends/defaults.py:112 26 | msgid "Your URL may have expired." 27 | msgstr "" 28 | 29 | #: organizations/base.py:162 organizations/forms.py:158 30 | msgid "The name of the organization" 31 | msgstr "" 32 | 33 | #: organizations/forms.py:59 34 | msgid "Only the organization owner can change ownerhip" 35 | msgstr "" 36 | 37 | #: organizations/forms.py:73 38 | msgid "The organization owner must be an admin" 39 | msgstr "" 40 | 41 | #: organizations/forms.py:100 42 | msgid "This email address has been used multiple times." 43 | msgstr "" 44 | 45 | #: organizations/forms.py:114 46 | msgid "There is already an organization member with this email address!" 47 | msgstr "" 48 | 49 | #: organizations/forms.py:125 50 | msgid "The email address for the account owner" 51 | msgstr "" 52 | 53 | #: organizations/forms.py:160 organizations/models.py:83 54 | msgid "The name in all lowercase, suitable for URL identification" 55 | msgstr "" 56 | 57 | #: organizations/mixins.py:94 58 | msgid "Wrong organization" 59 | msgstr "" 60 | 61 | #: organizations/mixins.py:109 62 | msgid "Sorry, admins only" 63 | msgstr "" 64 | 65 | #: organizations/mixins.py:124 66 | msgid "You are not the organization owner" 67 | msgstr "" 68 | 69 | #: organizations/models.py:86 70 | msgid "organization" 71 | msgstr "" 72 | 73 | #: organizations/models.py:87 74 | msgid "organizations" 75 | msgstr "" 76 | 77 | #: organizations/models.py:183 78 | msgid "organization user" 79 | msgstr "" 80 | 81 | #: organizations/models.py:184 82 | msgid "organization users" 83 | msgstr "" 84 | 85 | #: organizations/models.py:200 86 | msgid "" 87 | "Cannot delete organization owner before organization or transferring " 88 | "ownership." 89 | msgstr "" 90 | 91 | #: organizations/models.py:215 92 | msgid "organization owner" 93 | msgstr "" 94 | 95 | #: organizations/models.py:216 96 | msgid "organization owners" 97 | msgstr "" 98 | 99 | #: organizations/views.py:133 100 | msgid "Already active" 101 | msgstr "" 102 | -------------------------------------------------------------------------------- /tests/test_signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | from django.test.utils import override_settings 4 | 5 | from mock import call 6 | from mock_django.signals import mock_signal_receiver 7 | 8 | from organizations.models import Organization 9 | from organizations.signals import owner_changed 10 | from organizations.signals import user_added 11 | from organizations.signals import user_removed 12 | 13 | 14 | @override_settings(USE_TZ=True) 15 | class SignalsTestCase(TestCase): 16 | fixtures = ["users.json", "orgs.json"] 17 | 18 | def setUp(self): 19 | self.kurt = User.objects.get(username="kurt") 20 | self.dave = User.objects.get(username="dave") 21 | self.krist = User.objects.get(username="krist") 22 | self.duder = User.objects.get(username="duder") 23 | self.foo = Organization.objects.get(name="Foo Fighters") 24 | self.org = Organization.objects.get(name="Nirvana") 25 | self.admin = self.org.organization_users.get(user__username="krist") 26 | self.owner = self.org.organization_users.get(user__username="kurt") 27 | 28 | def test_user_added_called(self): 29 | with mock_signal_receiver(user_added) as add_receiver: 30 | self.foo.add_user(self.krist) 31 | 32 | self.assertEqual( 33 | add_receiver.call_args_list, 34 | [call(signal=user_added, sender=self.foo, user=self.krist)], 35 | ) 36 | 37 | with mock_signal_receiver(user_added) as add_receiver: 38 | self.foo.get_or_add_user(self.duder) 39 | 40 | self.assertEqual( 41 | add_receiver.call_args_list, 42 | [call(signal=user_added, sender=self.foo, user=self.duder)], 43 | ) 44 | 45 | def test_user_added_not_called(self): 46 | with mock_signal_receiver(user_added) as add_receiver: 47 | self.foo.get_or_add_user(self.dave) 48 | 49 | self.assertEqual(add_receiver.call_args_list, []) 50 | 51 | def test_user_removed_called(self): 52 | with mock_signal_receiver(user_removed) as remove_receiver: 53 | self.foo.add_user(self.krist) 54 | self.foo.remove_user(self.krist) 55 | 56 | self.assertEqual( 57 | remove_receiver.call_args_list, 58 | [call(signal=user_removed, sender=self.foo, user=self.krist)], 59 | ) 60 | 61 | def test_owner_changed_called(self): 62 | with mock_signal_receiver(owner_changed) as changed_receiver: 63 | self.org.change_owner(self.admin) 64 | 65 | self.assertEqual( 66 | changed_receiver.call_args_list, 67 | [ 68 | call( 69 | signal=owner_changed, 70 | sender=self.org, 71 | old=self.owner, 72 | new=self.admin, 73 | ) 74 | ], 75 | ) 76 | -------------------------------------------------------------------------------- /src/organizations/utils.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | 4 | def default_org_model(): 5 | """Encapsulates importing the concrete model""" 6 | from organizations.models import Organization 7 | 8 | return Organization 9 | 10 | 11 | def model_field_names(model): 12 | """ 13 | Returns a list of field names in the model 14 | 15 | Direct from Django upgrade migration guide. 16 | """ 17 | return list( 18 | set( 19 | chain.from_iterable( 20 | (field.name, field.attname) 21 | if hasattr(field, "attname") 22 | else (field.name,) 23 | for field in model._meta.get_fields() 24 | if not (field.many_to_one and field.related_model is None) 25 | ) 26 | ) 27 | ) 28 | 29 | 30 | def create_organization( 31 | user, 32 | name, 33 | slug=None, 34 | is_active=None, 35 | org_defaults=None, 36 | org_user_defaults=None, 37 | **kwargs, 38 | ): 39 | """ 40 | Returns a new organization, also creating an initial organization user who 41 | is the owner. 42 | 43 | The specific models can be specified if a custom organization app is used. 44 | The simplest way would be to use a partial. 45 | 46 | >>> from organizations.utils import create_organization 47 | >>> from myapp.models import Account 48 | >>> from functools import partial 49 | >>> create_account = partial(create_organization, model=Account) 50 | 51 | """ 52 | org_model = ( 53 | kwargs.pop("model", None) 54 | or kwargs.pop("org_model", None) 55 | or default_org_model() 56 | ) 57 | kwargs.pop("org_user_model", None) # Discard deprecated argument 58 | 59 | org_owner_model = org_model.owner.related.related_model 60 | org_user_model = org_model.organization_users.rel.related_model 61 | 62 | if org_defaults is None: 63 | org_defaults = {} 64 | if org_user_defaults is None: 65 | if "is_admin" in model_field_names(org_user_model): 66 | org_user_defaults = {"is_admin": True} 67 | else: 68 | org_user_defaults = {} 69 | 70 | if slug is not None: 71 | org_defaults.update({"slug": slug}) 72 | if is_active is not None: 73 | org_defaults.update({"is_active": is_active}) 74 | 75 | org_defaults.update({"name": name}) 76 | organization = org_model.objects.create(**org_defaults) 77 | 78 | org_user_defaults.update({"organization": organization, "user": user}) 79 | new_user = org_user_model.objects.create(**org_user_defaults) 80 | 81 | org_owner_model.objects.create( 82 | organization=organization, organization_user=new_user 83 | ) 84 | return organization 85 | 86 | 87 | def model_field_attr(model, model_field, attr): 88 | """ 89 | Returns the specified attribute for the specified field on the model class. 90 | """ 91 | fields = dict([(field.name, field) for field in model._meta.fields]) 92 | return getattr(fields[model_field], attr) 93 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Management command entry point for working with migrations 4 | """ 5 | 6 | import os 7 | import sys 8 | import django 9 | from django.conf import settings 10 | 11 | 12 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 13 | 14 | 15 | INSTALLED_APPS = [ 16 | "django.contrib.auth", 17 | "django.contrib.messages", 18 | "django.contrib.admin", 19 | "django.contrib.contenttypes", 20 | "django.contrib.sessions", 21 | "django.contrib.sites", 22 | # The ordering here, the apps using the organization base models 23 | # first and *then* the organizations app itself is an implicit test 24 | # that the organizations app need not be installed in order to use 25 | # its base models. 26 | "test_accounts", 27 | "test_abstract", 28 | "test_vendors", 29 | "organizations", 30 | "test_custom", 31 | ] 32 | 33 | settings.configure( 34 | DEBUG=True, 35 | USE_TZ=True, 36 | DATABASES={ 37 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.sqlite3"} 38 | }, 39 | MIDDLEWARE=[ 40 | "django.middleware.security.SecurityMiddleware", 41 | "django.contrib.sessions.middleware.SessionMiddleware", 42 | "django.middleware.common.CommonMiddleware", 43 | "django.middleware.csrf.CsrfViewMiddleware", 44 | "django.contrib.auth.middleware.AuthenticationMiddleware", 45 | "django.contrib.messages.middleware.MessageMiddleware", 46 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 47 | ], 48 | TEMPLATES=[ 49 | { 50 | "BACKEND": "django.template.backends.django.DjangoTemplates", 51 | "APP_DIRS": True, 52 | "OPTIONS": { 53 | "context_processors": [ 54 | "django.contrib.auth.context_processors.auth", 55 | "django.template.context_processors.debug", 56 | "django.template.context_processors.i18n", 57 | "django.template.context_processors.media", 58 | "django.template.context_processors.static", 59 | "django.template.context_processors.request", 60 | "django.contrib.messages.context_processors.messages", 61 | ], 62 | "debug": True, 63 | }, 64 | } 65 | ], 66 | SITE_ID=1, 67 | FIXTURE_DIRS=["tests/fixtures"], 68 | ORGS_SLUGFIELD="django_extensions.db.fields.AutoSlugField", 69 | INSTALLED_APPS=INSTALLED_APPS, 70 | ROOT_URLCONF="tests.urls", 71 | STATIC_URL="/static/", 72 | SECRET_KEY="ThisIsHorriblyInsecure", 73 | # STATIC_ROOT=os.path.join(BASE_DIR), 74 | # STATICFILES_DIRS = [ 75 | # os.path.join(BASE_DIR, "static"), 76 | # ], 77 | # SESSION_ENGINE='django.contrib.sessions.backends.db', 78 | ) 79 | 80 | django.setup() 81 | 82 | 83 | if __name__ == "__main__": 84 | from django.core.management import execute_from_command_line 85 | 86 | execute_from_command_line(sys.argv) 87 | -------------------------------------------------------------------------------- /test_abstract/migrations/0002_custominvitation.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-06-27 00:55 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | import organizations.base 8 | import organizations.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("test_abstract", "0001_initial"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="CustomInvitation", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("guid", models.UUIDField(editable=False)), 31 | ( 32 | "invitee_identifier", 33 | models.CharField( 34 | help_text=( 35 | "The contact identifier for the invitee, email, " 36 | "phone number, social media handle, etc." 37 | ), 38 | max_length=1000, 39 | ), 40 | ), 41 | ( 42 | "created", 43 | organizations.fields.AutoCreatedField( 44 | default=django.utils.timezone.now, editable=False 45 | ), 46 | ), 47 | ( 48 | "modified", 49 | organizations.fields.AutoLastModifiedField( 50 | default=django.utils.timezone.now, editable=False 51 | ), 52 | ), 53 | ( 54 | "invited_by", 55 | models.ForeignKey( 56 | on_delete=django.db.models.deletion.CASCADE, 57 | related_name="test_abstract_custominvitation_sent_invitations", 58 | to=settings.AUTH_USER_MODEL, 59 | ), 60 | ), 61 | ( 62 | "invitee", 63 | models.ForeignKey( 64 | blank=True, 65 | null=True, 66 | on_delete=django.db.models.deletion.CASCADE, 67 | related_name="test_abstract_custominvitation_invitations", 68 | to=settings.AUTH_USER_MODEL, 69 | ), 70 | ), 71 | ( 72 | "organization", 73 | models.ForeignKey( 74 | on_delete=django.db.models.deletion.CASCADE, 75 | related_name="organization_invites", 76 | to="test_abstract.CustomOrganization", 77 | ), 78 | ), 79 | ], 80 | options={"abstract": False}, 81 | ) 82 | ] 83 | -------------------------------------------------------------------------------- /src/organizations/migrations/0004_organizationinvitation.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2019-06-27 00:54 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations 7 | from django.db import models 8 | 9 | import organizations.base 10 | import organizations.fields 11 | 12 | 13 | class Migration(migrations.Migration): 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ("organizations", "0003_field_fix_and_editable"), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name="OrganizationInvitation", 22 | fields=[ 23 | ( 24 | "id", 25 | models.AutoField( 26 | auto_created=True, 27 | primary_key=True, 28 | serialize=False, 29 | verbose_name="ID", 30 | ), 31 | ), 32 | ("guid", models.UUIDField(editable=False)), 33 | ( 34 | "invitee_identifier", 35 | models.CharField( 36 | help_text=( 37 | "The contact identifier for the invitee, email, " 38 | "phone number, social media handle, etc." 39 | ), 40 | max_length=1000, 41 | ), 42 | ), 43 | ( 44 | "created", 45 | organizations.fields.AutoCreatedField( 46 | default=django.utils.timezone.now, editable=False 47 | ), 48 | ), 49 | ( 50 | "modified", 51 | organizations.fields.AutoLastModifiedField( 52 | default=django.utils.timezone.now, editable=False 53 | ), 54 | ), 55 | ( 56 | "invited_by", 57 | models.ForeignKey( 58 | on_delete=django.db.models.deletion.CASCADE, 59 | related_name="organizations_organizationinvitation_sent_invitations", 60 | to=settings.AUTH_USER_MODEL, 61 | ), 62 | ), 63 | ( 64 | "invitee", 65 | models.ForeignKey( 66 | blank=True, 67 | null=True, 68 | on_delete=django.db.models.deletion.CASCADE, 69 | related_name="organizations_organizationinvitation_invitations", 70 | to=settings.AUTH_USER_MODEL, 71 | ), 72 | ), 73 | ( 74 | "organization", 75 | models.ForeignKey( 76 | on_delete=django.db.models.deletion.CASCADE, 77 | related_name="organization_invites", 78 | to="organizations.Organization", 79 | ), 80 | ), 81 | ], 82 | options={"abstract": False}, 83 | ) 84 | ] 85 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | 7 | from organizations.models import Organization 8 | from organizations.utils import create_organization 9 | from organizations.utils import model_field_attr 10 | from test_abstract.models import CustomOrganization 11 | from test_accounts.models import Account 12 | 13 | 14 | @override_settings(USE_TZ=True) 15 | class CreateOrgTests(TestCase): 16 | fixtures = ["users.json", "orgs.json"] 17 | 18 | def setUp(self): 19 | self.user = User.objects.get(username="dave") 20 | 21 | def test_create_organization(self): 22 | acme = create_organization( 23 | self.user, "Acme", org_defaults={"slug": "acme-slug"} 24 | ) 25 | self.assertTrue(isinstance(acme, Organization)) 26 | self.assertEqual(self.user, acme.owner.organization_user.user) 27 | self.assertTrue(acme.owner.organization_user.is_admin) 28 | 29 | def test_create_custom_org(self): 30 | custom = create_organization(self.user, "Custom", model=Account) 31 | self.assertTrue(isinstance(custom, Account)) 32 | self.assertEqual(self.user, custom.owner.organization_user.user) 33 | 34 | def test_create_custom_org_from_abstract(self): 35 | custom = create_organization(self.user, "Custom", model=CustomOrganization) 36 | self.assertTrue(isinstance(custom, CustomOrganization)) 37 | self.assertEqual(self.user, custom.owner.organization_user.user) 38 | 39 | def test_defaults(self): 40 | """Ensure models are created with defaults as specified""" 41 | # Default models 42 | org = create_organization( 43 | self.user, 44 | "Is Admin", 45 | org_defaults={"slug": "is-admin-212", "is_active": False}, 46 | org_user_defaults={"is_admin": False}, 47 | ) 48 | self.assertFalse(org.is_active) 49 | self.assertFalse(org.owner.organization_user.is_admin) 50 | 51 | # Custom models 52 | create_account = partial( 53 | create_organization, 54 | model=Account, 55 | org_defaults={"monthly_subscription": 99}, 56 | org_user_defaults={"user_type": "B"}, 57 | ) 58 | myaccount = create_account(self.user, name="My New Account") 59 | self.assertEqual(myaccount.monthly_subscription, 99) 60 | 61 | def test_backwards_compat(self): 62 | """Ensure old optional arguments still work""" 63 | org = create_organization(self.user, "Is Admin", "my-slug", is_active=False) 64 | self.assertFalse(org.is_active) 65 | 66 | custom = create_organization(self.user, "Custom org", org_model=Account) 67 | self.assertTrue(isinstance(custom, Account)) 68 | 69 | 70 | class AttributeUtilTests(TestCase): 71 | def test_present_field(self): 72 | self.assertTrue(model_field_attr(User, "username", "max_length")) 73 | 74 | def test_absent_field(self): 75 | self.assertRaises(KeyError, model_field_attr, User, "blahblah", "max_length") 76 | 77 | def test_absent_attr(self): 78 | self.assertRaises( 79 | AttributeError, model_field_attr, User, "username", "mariopoints" 80 | ) 81 | -------------------------------------------------------------------------------- /src/organizations/locale/en_GB/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-05-14 20:09-0400\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: organizations/backends/defaults.py:68 22 | msgid "You must define a form_class" 23 | msgstr "" 24 | 25 | #: organizations/backends/defaults.py:110 26 | #: organizations/backends/defaults.py:112 27 | msgid "Your URL may have expired." 28 | msgstr "" 29 | 30 | #: organizations/base.py:162 organizations/forms.py:158 31 | msgid "The name of the organization" 32 | msgstr "The name of the organisation" 33 | 34 | #: organizations/forms.py:59 35 | msgid "Only the organization owner can change ownerhip" 36 | msgstr "Only the organisation owner can change ownerhip" 37 | 38 | #: organizations/forms.py:73 39 | msgid "The organization owner must be an admin" 40 | msgstr "The organisation owner must be an admin" 41 | 42 | #: organizations/forms.py:100 43 | msgid "This email address has been used multiple times." 44 | msgstr "" 45 | 46 | #: organizations/forms.py:114 47 | msgid "There is already an organization member with this email address!" 48 | msgstr "There is already an organisation member with this email address!" 49 | 50 | #: organizations/forms.py:125 51 | msgid "The email address for the account owner" 52 | msgstr "" 53 | 54 | #: organizations/forms.py:160 organizations/models.py:83 55 | msgid "The name in all lowercase, suitable for URL identification" 56 | msgstr "" 57 | 58 | #: organizations/mixins.py:94 59 | msgid "Wrong organization" 60 | msgstr "Wrong organisation" 61 | 62 | #: organizations/mixins.py:109 63 | msgid "Sorry, admins only" 64 | msgstr "" 65 | 66 | #: organizations/mixins.py:124 67 | msgid "You are not the organization owner" 68 | msgstr "You are not the organisation owner" 69 | 70 | #: organizations/models.py:86 71 | msgid "organization" 72 | msgstr "organisation" 73 | 74 | #: organizations/models.py:87 75 | msgid "organizations" 76 | msgstr "organisations" 77 | 78 | #: organizations/models.py:183 79 | msgid "organization user" 80 | msgstr "organisation user" 81 | 82 | #: organizations/models.py:184 83 | msgid "organization users" 84 | msgstr "organisation users" 85 | 86 | #: organizations/models.py:200 87 | msgid "" 88 | "Cannot delete organization owner before organization or transferring " 89 | "ownership." 90 | msgstr "" 91 | "Cannot delete organisation owner before organisation or transferring " 92 | "ownership." 93 | 94 | #: organizations/models.py:215 95 | msgid "organization owner" 96 | msgstr "organisation owner" 97 | 98 | #: organizations/models.py:216 99 | msgid "organization owners" 100 | msgstr "organisation owners" 101 | 102 | #: organizations/views.py:133 103 | msgid "Already active" 104 | msgstr "" 105 | -------------------------------------------------------------------------------- /src/organizations/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | Most of this code extracted and borrowed from django-model-utils 3 | 4 | Copyright (c) 2009-2015, Carl Meyer and contributors 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | * Redistributions of source code must retain the above copyright 12 | notice, this list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above 14 | copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided 16 | with the distribution. 17 | * Neither the name of the author nor the names of other 18 | contributors may be used to endorse or promote products derived 19 | from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | """ 33 | 34 | from importlib import import_module 35 | 36 | from django.conf import settings 37 | from django.core.exceptions import ImproperlyConfigured 38 | from django.db import models 39 | from django.utils.timezone import now 40 | 41 | 42 | class AutoCreatedField(models.DateTimeField): 43 | """ 44 | A DateTimeField that automatically populates itself at 45 | object creation. 46 | 47 | By default, sets editable=False, default=datetime.now. 48 | 49 | """ 50 | 51 | def __init__(self, *args, **kwargs): 52 | kwargs.setdefault("editable", False) 53 | kwargs.setdefault("default", now) 54 | super().__init__(*args, **kwargs) 55 | 56 | 57 | class AutoLastModifiedField(AutoCreatedField): 58 | """ 59 | A DateTimeField that updates itself on each save() of the model. 60 | 61 | By default, sets editable=False and default=datetime.now. 62 | 63 | """ 64 | 65 | def pre_save(self, model_instance, add): 66 | value = now() 67 | setattr(model_instance, self.attname, value) 68 | return value 69 | 70 | 71 | ORGS_SLUGFIELD = getattr( 72 | settings, "ORGS_SLUGFIELD", "django_extensions.db.fields.AutoSlugField" 73 | ) 74 | 75 | try: 76 | module, klass = ORGS_SLUGFIELD.rsplit(".", 1) 77 | BaseSlugField = getattr(import_module(module), klass) 78 | except (ImportError, ValueError, AttributeError): 79 | raise ImproperlyConfigured( 80 | "Your SlugField class, '{0}', is improperly defined. " 81 | "See the documentation and install an auto slug field".format(ORGS_SLUGFIELD) 82 | ) 83 | 84 | 85 | class SlugField(BaseSlugField): 86 | """Class redefinition for migrations""" 87 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """ 2 | nox configuration to systematize release and pre-release tasks 3 | 4 | Several nox tasks copied from the pipx project, Copyright (c) 2018 Chad Smith 5 | """ 6 | 7 | import os 8 | import subprocess 9 | 10 | import nox 11 | 12 | NOX_DIR = os.path.abspath(os.path.dirname(__file__)) 13 | 14 | DEFAULT_INTERPRETER = "3.8" 15 | ALL_INTERPRETERS = (DEFAULT_INTERPRETER,) 16 | 17 | 18 | DEV_INSTALL_REQUIREMENTS = ["django-autoslug"] 19 | 20 | 21 | def get_path(*names): 22 | return os.path.join(NOX_DIR, *names) 23 | 24 | 25 | @nox.session(python=DEFAULT_INTERPRETER, reuse_venv=True) 26 | def manage(session, *args): 27 | """ 28 | Runs management commands in a nox environment 29 | 30 | The use for this command is primarily here for running 31 | migrations, but it can be used to run any Django command, 32 | e.g. running a quick dev server (though that much is 33 | expected to be of little benefit). 34 | 35 | Args: 36 | session: nox's session 37 | *args: either direct arguments from the command line 38 | or passed through from another command. This 39 | makes the command function reusable from more 40 | explicitly named commands 41 | 42 | Returns: 43 | None 44 | 45 | """ 46 | session.install("six") 47 | session.install("django-autoslug") 48 | session.install("Django==3.1") 49 | session.install("-e", ".") 50 | args = args if args else session.posargs 51 | session.run("python", "manage.py", *args) 52 | 53 | 54 | @nox.session 55 | def clean(session): 56 | """Removes build artifacts""" 57 | for rmdir in ["build/", "dist/", "*.egg-info"]: 58 | session.run("rm", "-rf", rmdir, external=True) 59 | 60 | 61 | @nox.session(python=DEFAULT_INTERPRETER) 62 | def build(session): 63 | session.install("setuptools") 64 | session.install("wheel") 65 | session.install("docutils") 66 | session.install("twine") 67 | clean(session) 68 | session.run("python", "setup.py", "--quiet", "sdist", "bdist_wheel") 69 | 70 | 71 | def has_changes(): 72 | status = ( 73 | subprocess.run( 74 | "git status --porcelain", shell=True, check=True, stdout=subprocess.PIPE 75 | ) 76 | .stdout.decode() 77 | .strip() 78 | ) 79 | return len(status) > 0 80 | 81 | 82 | def get_branch(): 83 | return ( 84 | subprocess.run( 85 | "git rev-parse --abbrev-ref HEAD", 86 | shell=True, 87 | check=True, 88 | stdout=subprocess.PIPE, 89 | ) 90 | .stdout.decode() 91 | .strip() 92 | ) 93 | 94 | 95 | @nox.session(python=DEFAULT_INTERPRETER) 96 | def publish(session): 97 | if has_changes(): 98 | session.error("All changes must be committed or removed before publishing") 99 | branch = get_branch() 100 | if branch != "master": 101 | session.error(f"Must be on 'master' branch. Currently on {branch!r} branch") 102 | build(session) 103 | session.run("twine", "check", "dist/*") 104 | print("REMINDER: Has the changelog been updated?") 105 | session.run("python", "-m", "twine", "upload", "dist/*") 106 | 107 | 108 | @nox.session(python=DEFAULT_INTERPRETER, reuse_venv=True) 109 | def docs(session): 110 | session.run("make", "-C", "docs", "clean") 111 | session.run("make", "-C", "docs", "html") 112 | session.run("open", "docs/_build/html/index.html") 113 | -------------------------------------------------------------------------------- /src/organizations/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.urls import include 3 | from django.urls import path 4 | 5 | from organizations.views import default as views 6 | 7 | # app_name = "organizations" 8 | 9 | urlpatterns = [ 10 | path( 11 | "", 12 | view=login_required(views.OrganizationList.as_view()), 13 | name="organization_list", 14 | ), 15 | path( 16 | "add/", 17 | view=login_required(views.OrganizationCreate.as_view()), 18 | name="organization_add", 19 | ), 20 | path( 21 | "/", 22 | include( 23 | [ 24 | path( 25 | "", 26 | view=login_required(views.OrganizationDetail.as_view()), 27 | name="organization_detail", 28 | ), 29 | path( 30 | "edit/", 31 | view=login_required(views.OrganizationUpdate.as_view()), 32 | name="organization_edit", 33 | ), 34 | path( 35 | "delete/", 36 | view=login_required(views.OrganizationDelete.as_view()), 37 | name="organization_delete", 38 | ), 39 | path( 40 | "people/", 41 | include( 42 | [ 43 | path( 44 | "", 45 | view=login_required( 46 | views.OrganizationUserList.as_view() 47 | ), 48 | name="organization_user_list", 49 | ), 50 | path( 51 | "add/", 52 | view=login_required( 53 | views.OrganizationUserCreate.as_view() 54 | ), 55 | name="organization_user_add", 56 | ), 57 | path( 58 | "/remind/", 59 | view=login_required( 60 | views.OrganizationUserRemind.as_view() 61 | ), 62 | name="organization_user_remind", 63 | ), 64 | path( 65 | "/", 66 | view=login_required( 67 | views.OrganizationUserDetail.as_view() 68 | ), 69 | name="organization_user_detail", 70 | ), 71 | path( 72 | "/edit/", 73 | view=login_required( 74 | views.OrganizationUserUpdate.as_view() 75 | ), 76 | name="organization_user_edit", 77 | ), 78 | path( 79 | "/delete/", 80 | view=login_required( 81 | views.OrganizationUserDelete.as_view() 82 | ), 83 | name="organization_user_delete", 84 | ), 85 | ] 86 | ), 87 | ), 88 | ] 89 | ), 90 | ), 91 | ] 92 | -------------------------------------------------------------------------------- /tests/fixtures/orgs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "organizations.organization", 5 | "fields": { 6 | "is_active": true, 7 | "modified": "2012-05-25T21:16:30.126Z", 8 | "name": "Foo Fighters", 9 | "slug": "foo-fighters", 10 | "created": "2012-05-24T20:42:07.894Z" 11 | } 12 | }, 13 | { 14 | "pk": 2, 15 | "model": "organizations.organization", 16 | "fields": { 17 | "is_active": true, 18 | "modified": "2012-05-25T21:16:37.827Z", 19 | "name": "Nirvana", 20 | "slug": "nirvana", 21 | "created": "2012-05-25T21:14:34.818Z" 22 | } 23 | }, 24 | { 25 | "pk": 3, 26 | "model": "organizations.organization", 27 | "fields": { 28 | "is_active": false, 29 | "modified": "2012-05-26T04:03:38.182Z", 30 | "name": "Scream", 31 | "slug": "scream", 32 | "created": "2012-05-26T04:03:21.839Z" 33 | } 34 | }, 35 | { 36 | "pk": 1, 37 | "model": "organizations.organizationuser", 38 | "fields": { 39 | "organization": 1, 40 | "user": 1, 41 | "is_admin": true, 42 | "modified": "2012-05-24T20:42:07.906Z", 43 | "created": "2012-05-24T20:42:07.906Z" 44 | } 45 | }, 46 | { 47 | "pk": 2, 48 | "model": "organizations.organizationuser", 49 | "fields": { 50 | "organization": 2, 51 | "user": 1, 52 | "is_admin": false, 53 | "modified": "2012-05-26T01:27:51.941Z", 54 | "created": "2012-05-26T01:27:51.941Z" 55 | } 56 | }, 57 | { 58 | "pk": 3, 59 | "model": "organizations.organizationuser", 60 | "fields": { 61 | "organization": 2, 62 | "user": 2, 63 | "is_admin": true, 64 | "modified": "2012-05-26T01:28:02.221Z", 65 | "created": "2012-05-26T01:28:02.221Z" 66 | } 67 | }, 68 | { 69 | "pk": 4, 70 | "model": "organizations.organizationuser", 71 | "fields": { 72 | "organization": 2, 73 | "user": 3, 74 | "is_admin": true, 75 | "modified": "2012-05-26T01:28:06.247Z", 76 | "created": "2012-05-26T01:28:06.247Z" 77 | } 78 | }, 79 | { 80 | "pk": 5, 81 | "model": "organizations.organizationuser", 82 | "fields": { 83 | "organization": 3, 84 | "user": 1, 85 | "is_admin": true, 86 | "modified": "2012-05-26T04:03:57.353Z", 87 | "created": "2012-05-26T04:03:57.353Z" 88 | } 89 | }, 90 | { 91 | "pk": 1, 92 | "model": "organizations.organizationowner", 93 | "fields": { 94 | "organization_user": 1, 95 | "organization": 1, 96 | "modified": "2012-05-24T20:42:07.916Z", 97 | "created": "2012-05-24T20:42:07.916Z" 98 | } 99 | }, 100 | { 101 | "pk": 2, 102 | "model": "organizations.organizationowner", 103 | "fields": { 104 | "organization_user": 4, 105 | "organization": 2, 106 | "modified": "2012-05-26T01:28:19.145Z", 107 | "created": "2012-05-26T01:28:19.145Z" 108 | } 109 | }, 110 | { 111 | "pk": 3, 112 | "model": "organizations.organizationowner", 113 | "fields": { 114 | "organization_user": 5, 115 | "organization": 3, 116 | "modified": "2012-05-26T04:06:07.650Z", 117 | "created": "2012-05-26T04:06:07.650Z" 118 | } 119 | } 120 | ] 121 | -------------------------------------------------------------------------------- /test_accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2017-12-05 00:17 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Account", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "name", 28 | models.CharField( 29 | help_text="The name of the organization", max_length=200 30 | ), 31 | ), 32 | ("is_active", models.BooleanField(default=True)), 33 | ("monthly_subscription", models.IntegerField(default=1000)), 34 | ], 35 | options={"ordering": ["name"], "abstract": False}, 36 | ), 37 | migrations.CreateModel( 38 | name="AccountOwner", 39 | fields=[ 40 | ( 41 | "id", 42 | models.AutoField( 43 | auto_created=True, 44 | primary_key=True, 45 | serialize=False, 46 | verbose_name="ID", 47 | ), 48 | ), 49 | ( 50 | "organization", 51 | models.OneToOneField( 52 | on_delete=django.db.models.deletion.CASCADE, 53 | related_name="owner", 54 | to="test_accounts.Account", 55 | ), 56 | ), 57 | ], 58 | options={"abstract": False}, 59 | ), 60 | migrations.CreateModel( 61 | name="AccountUser", 62 | fields=[ 63 | ( 64 | "id", 65 | models.AutoField( 66 | auto_created=True, 67 | primary_key=True, 68 | serialize=False, 69 | verbose_name="ID", 70 | ), 71 | ), 72 | ("user_type", models.CharField(default="", max_length=1)), 73 | ( 74 | "organization", 75 | models.ForeignKey( 76 | on_delete=django.db.models.deletion.CASCADE, 77 | related_name="organization_users", 78 | to="test_accounts.Account", 79 | ), 80 | ), 81 | ( 82 | "user", 83 | models.ForeignKey( 84 | on_delete=django.db.models.deletion.CASCADE, 85 | related_name="test_accounts_accountuser", 86 | to=settings.AUTH_USER_MODEL, 87 | ), 88 | ), 89 | ], 90 | options={"ordering": ["organization", "user"], "abstract": False}, 91 | ), 92 | migrations.AddField( 93 | model_name="accountowner", 94 | name="organization_user", 95 | field=models.OneToOneField( 96 | on_delete=django.db.models.deletion.CASCADE, 97 | to="test_accounts.AccountUser", 98 | ), 99 | ), 100 | migrations.AddField( 101 | model_name="account", 102 | name="users", 103 | field=models.ManyToManyField( 104 | related_name="test_accounts_account", 105 | through="test_accounts.AccountUser", 106 | to=settings.AUTH_USER_MODEL, 107 | ), 108 | ), 109 | migrations.AlterUniqueTogether( 110 | name="accountuser", unique_together={("user", "organization")} 111 | ), 112 | ] 113 | -------------------------------------------------------------------------------- /src/organizations/views/mixins.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | from django.shortcuts import get_object_or_404 3 | from django.utils.functional import cached_property 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from organizations.models import Organization 7 | from organizations.models import OrganizationUser 8 | 9 | 10 | class OrganizationMixin: 11 | """Mixin used like a SingleObjectMixin to fetch an organization""" 12 | 13 | org_model = Organization 14 | org_context_name = "organization" 15 | 16 | def get_org_model(self): 17 | return self.org_model 18 | 19 | def get_context_data(self, **kwargs): 20 | kwargs.update({self.org_context_name: self.organization}) 21 | return super().get_context_data(**kwargs) 22 | 23 | @cached_property 24 | def organization(self): 25 | organization_pk = self.kwargs.get("organization_pk", None) 26 | return get_object_or_404(self.get_org_model(), pk=organization_pk) 27 | 28 | def get_object(self): 29 | return self.organization 30 | 31 | get_organization = get_object # Now available when `get_object` is overridden 32 | 33 | 34 | class OrganizationUserMixin(OrganizationMixin): 35 | """Mixin used like a SingleObjectMixin to fetch an organization user""" 36 | 37 | user_model = OrganizationUser 38 | org_user_context_name = "organization_user" 39 | 40 | def get_user_model(self): 41 | return self.user_model 42 | 43 | def get_context_data(self, **kwargs): 44 | kwargs = super().get_context_data(**kwargs) 45 | kwargs.update( 46 | { 47 | self.org_user_context_name: self.object, 48 | self.org_context_name: self.object.organization, 49 | } 50 | ) 51 | return kwargs 52 | 53 | @cached_property 54 | def organization_user(self): 55 | """ 56 | Returns the OrganizationUser object 57 | 58 | This is fetched based on the primary keys for both 59 | the organization and the organization user. 60 | """ 61 | organization_pk = self.kwargs.get("organization_pk", None) 62 | user_pk = self.kwargs.get("user_pk", None) 63 | return get_object_or_404( 64 | self.get_user_model().objects.select_related(), 65 | user__pk=user_pk, 66 | organization__pk=organization_pk, 67 | ) 68 | 69 | def get_object(self): 70 | """Proxy for the base class interface 71 | 72 | This can be called all day long and the object is queried once. 73 | """ 74 | return self.organization_user 75 | 76 | 77 | class MembershipRequiredMixin: 78 | """This mixin presumes that authentication has already been checked""" 79 | 80 | def dispatch(self, request, *args, **kwargs): 81 | self.request = request 82 | self.args = args 83 | self.kwargs = kwargs 84 | if ( 85 | not self.organization.is_member(request.user) 86 | and not request.user.is_superuser 87 | ): 88 | raise PermissionDenied(_("Wrong organization")) 89 | return super().dispatch(request, *args, **kwargs) 90 | 91 | 92 | class AdminRequiredMixin: 93 | """This mixin presumes that authentication has already been checked""" 94 | 95 | def dispatch(self, request, *args, **kwargs): 96 | self.request = request 97 | self.args = args 98 | self.kwargs = kwargs 99 | if ( 100 | not self.organization.is_admin(request.user) 101 | and not request.user.is_superuser 102 | ): 103 | raise PermissionDenied(_("Sorry, admins only")) 104 | return super().dispatch(request, *args, **kwargs) 105 | 106 | 107 | class OwnerRequiredMixin: 108 | """This mixin presumes that authentication has already been checked""" 109 | 110 | def dispatch(self, request, *args, **kwargs): 111 | self.request = request 112 | self.args = args 113 | self.kwargs = kwargs 114 | if ( 115 | self.organization.owner.organization_user.user != request.user 116 | and not request.user.is_superuser 117 | ): 118 | raise PermissionDenied(_("You are not the organization owner")) 119 | return super().dispatch(request, *args, **kwargs) 120 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Basic usage 3 | =========== 4 | 5 | After installing django-organizations you can make basic use of the accounts 6 | with minimal configuration. 7 | 8 | .. note:: 9 | 10 | For custom usage, including custom account models, see the section on custom usage, 11 | or for deeper customization including fully customized account and linking models, 12 | see the cookbook. 13 | 14 | Model relationships 15 | =================== 16 | 17 | One of the core benefits of using a multi user account is the ability to tie 18 | other objects to an account. 19 | 20 | Keep your organization models at the top of your *app hierarchy* and relate 21 | these models *back* to your organization model.:: 22 | 23 | class Product(models.Model): 24 | account = models.ForeignKey( 25 | 'organizations.Organization', 26 | related_name='products', 27 | ) 28 | 29 | 30 | You can simplify access checks with a queryset method.:: 31 | 32 | class ProductQuerySet(models.QuerySet): 33 | def for_user(self, user): 34 | return self.filter(account__users=user) 35 | 36 | Views & mixins 37 | ============== 38 | 39 | The views in the rest of your application can then filter by account.:: 40 | 41 | class OrganizationMixin(object): 42 | 43 | 44 | 45 | The application's default views and URL configuration provide functionality for 46 | account creation, user registration, and account management. 47 | 48 | Creating accounts 49 | ----------------- 50 | 51 | .. note:: 52 | This is a to-do item, and an opportunity to contribute to the project! 53 | 54 | User registration 55 | ----------------- 56 | 57 | You can register new users and organizations through your project's own system 58 | or use the extensible invitation and registration backends. 59 | 60 | The default invitation backend accepts an email address and returns the user 61 | who either matches that email address or creates a new user with that email 62 | address. The view for adding a new user is then responsible for adding this 63 | user to the organization. 64 | 65 | The `OrganizationSignup` view is used for allowing a user new to the site to 66 | create an organization and account. This view relies on the registration 67 | backend to create and verify a new user. 68 | 69 | The backends can be extended to fit the needs of a given site. 70 | 71 | Creating accounts 72 | ~~~~~~~~~~~~~~~~~ 73 | 74 | When a new user signs up to create an account - meaning a new UserAccount for a 75 | new Account - the view creates a new `User`, a new `Account`, a new 76 | `AccountUser`, and a new `AccountOwner` object linking the newly created 77 | `Account` and `AccountUser`. 78 | 79 | Adding users 80 | ~~~~~~~~~~~~ 81 | 82 | The user registration system in django-organizations is based on the same 83 | token generating mechanism as Django's password reset functionality. 84 | 85 | Changing ownership 86 | ------------------ 87 | 88 | Changing ownership of an organization is as simple as updating the 89 | `OrganizationOwner` such that it points to the new user. There is as of yet no 90 | out of the box view to do this, but adding your own will be trivial. 91 | 92 | Invitation & registration backends 93 | ================================== 94 | 95 | The invitation and registration backends provide a way for your account users 96 | to add new users to their accounts and if your application allows it, for users 97 | to create their own accounts at registration. Each base backend class is 98 | designed to provide a common interface which your backend classes can use to 99 | work with whatever user models, registration systems, additional account 100 | systems, or any other tools you need for your site. 101 | 102 | Using template tags 103 | =================== 104 | 105 | Django-organizations comes with following template tags: 106 | 107 | * organization_users 108 | * is_admin 109 | * is_owner 110 | 111 | Example usage of template tags in your templates:.:: 112 | 113 | {% load org_tags %} 114 | 115 | {# somewhere in your template you have `organization` and `user` variable #} 116 | 117 | {% if organization|is_admin:user %} 118 | {{ user }} is a administrator of the {{ organization.name }} organization. 119 | {% elif organization|is_owner:user %} 120 | {{ user }} is owner of the {{ organization.name }} organization. 121 | {% endif %} 122 | -------------------------------------------------------------------------------- /test_vendors/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2017-12-05 00:17 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Vendor", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "name", 28 | models.CharField( 29 | help_text="The name of the organization", max_length=200 30 | ), 31 | ), 32 | ("is_active", models.BooleanField(default=True)), 33 | ("street_address", models.CharField(default="", max_length=100)), 34 | ("city", models.CharField(default="", max_length=100)), 35 | ], 36 | options={"ordering": ["name"], "abstract": False}, 37 | ), 38 | migrations.CreateModel( 39 | name="VendorOwner", 40 | fields=[ 41 | ( 42 | "id", 43 | models.AutoField( 44 | auto_created=True, 45 | primary_key=True, 46 | serialize=False, 47 | verbose_name="ID", 48 | ), 49 | ), 50 | ( 51 | "organization", 52 | models.OneToOneField( 53 | on_delete=django.db.models.deletion.CASCADE, 54 | related_name="owner", 55 | to="test_vendors.Vendor", 56 | ), 57 | ), 58 | ], 59 | options={"abstract": False}, 60 | ), 61 | migrations.CreateModel( 62 | name="VendorUser", 63 | fields=[ 64 | ( 65 | "id", 66 | models.AutoField( 67 | auto_created=True, 68 | primary_key=True, 69 | serialize=False, 70 | verbose_name="ID", 71 | ), 72 | ), 73 | ("user_type", models.CharField(default="", max_length=1)), 74 | ( 75 | "organization", 76 | models.ForeignKey( 77 | on_delete=django.db.models.deletion.CASCADE, 78 | related_name="organization_users", 79 | to="test_vendors.Vendor", 80 | ), 81 | ), 82 | ( 83 | "permissions", 84 | models.ManyToManyField(blank=True, to="auth.Permission"), 85 | ), 86 | ( 87 | "user", 88 | models.ForeignKey( 89 | on_delete=django.db.models.deletion.CASCADE, 90 | related_name="test_vendors_vendoruser", 91 | to=settings.AUTH_USER_MODEL, 92 | ), 93 | ), 94 | ], 95 | options={"ordering": ["organization", "user"], "abstract": False}, 96 | ), 97 | migrations.AddField( 98 | model_name="vendorowner", 99 | name="organization_user", 100 | field=models.OneToOneField( 101 | on_delete=django.db.models.deletion.CASCADE, 102 | to="test_vendors.VendorUser", 103 | ), 104 | ), 105 | migrations.AddField( 106 | model_name="vendor", 107 | name="users", 108 | field=models.ManyToManyField( 109 | related_name="test_vendors_vendor", 110 | through="test_vendors.VendorUser", 111 | to=settings.AUTH_USER_MODEL, 112 | ), 113 | ), 114 | migrations.AlterUniqueTogether( 115 | name="vendoruser", unique_together={("user", "organization")} 116 | ), 117 | ] 118 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps and credit will always be given. 7 | 8 | Please *do* read through this first before you start contributing code. The 9 | Feature Scope section will be of particular interest to those looking to expand 10 | on this app's functionality, and the Pull Request Guidelines explain how I add 11 | code contributions. 12 | 13 | **Bug fix pull requests should be made against the MASTER branch.** 14 | 15 | Types of Contributions 16 | ====================== 17 | 18 | Report Bugs 19 | ~~~~~~~~~~~ 20 | 21 | Report bugs at https://github.com/bennylope/django-organizations/issues. 22 | 23 | If you are reporting a bug, please include: 24 | 25 | * Your operating system name and version. 26 | * Python version 27 | * Django version 28 | * Django Organizations version 29 | * Any additional details about your local setup that might be helpful in troubleshooting. 30 | * Detailed steps to reproduce the bug. 31 | 32 | Fix Bugs 33 | ~~~~~~~~ 34 | 35 | Look through the GitHub issues for bugs. Anything tagged with "bug" 36 | is open to whoever wants to implement it. 37 | 38 | Implement Features 39 | ~~~~~~~~~~~~~~~~~~ 40 | 41 | Look through the GitHub issues for features. Anything tagged with "feature" 42 | is open to whoever wants to implement it. 43 | 44 | Write Documentation 45 | ~~~~~~~~~~~~~~~~~~~ 46 | 47 | Django-organizations could always use more documentation, whether as part of the 48 | official Django-organizations docs, in docstrings, or even on the web in blog posts, 49 | articles, and such. 50 | 51 | Submit Feedback 52 | ~~~~~~~~~~~~~~~ 53 | 54 | The best way to send feedback is to file an issue at https://github.com/bennylope/django-organizations/issues. 55 | 56 | If you are proposing a feature: 57 | 58 | * Explain in detail how it would work. 59 | * Keep the scope as narrow as possible, to make it easier to implement. 60 | * Remember that this is a volunteer-driven project, and that contributions 61 | are welcome 62 | 63 | Get Started! 64 | ------------ 65 | 66 | Ready to contribute? Here's how to set up `django-organizations` for local development. 67 | 68 | 1. Fork the `django-organizations` repo on GitHub. 69 | 2. Clone your fork locally:: 70 | 71 | $ git clone git@github.com:your_name_here/django-organizations.git 72 | 73 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 74 | 75 | $ mkvirtualenv django-organizations 76 | $ cd django-organizations/ 77 | $ pip install -r requirements-dev.txt 78 | 79 | 4. Create a branch for local development:: 80 | 81 | $ git checkout -b name-of-your-bugfix-or-feature 82 | 83 | Now you can make your changes locally. 84 | 85 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 86 | 87 | $ tox 88 | 89 | To get flake8 and tox, just pip install them into your virtualenv. 90 | 91 | 6. Commit your changes and push your branch to GitHub:: 92 | 93 | $ git add . 94 | $ git commit -m "Your detailed description of your changes." 95 | $ git push origin name-of-your-bugfix-or-feature 96 | 97 | 7. Submit a pull request through the GitHub website. 98 | 99 | Feature Scope 100 | ============= 101 | 102 | Django Organizations should make working with group account simple by providing 103 | patterns and code to perform core functionality. There's potential overlap with 104 | user management, authentication, etc, and it should do none of those things. 105 | 106 | Most feature suggestions can be accomplished with basic customization in an 107 | end-user's project. If something requires a bit more work, it'd be better to 108 | modify Django Organizations to allow new custom functionality, rather than 109 | adding that in directly. 110 | 111 | Pull Request Guidelines 112 | ======================= 113 | 114 | **Bug fix pull requests should be made against the MASTER branch.** 115 | 116 | Before you submit a pull request, check that it meets these guidelines: 117 | 118 | 1. The pull request should include tests. 119 | 2. If the pull request adds functionality, the docs should be updated. Put 120 | your new functionality into a function with a docstring, and add the 121 | feature to the list in README.rst. Any new functionality should align with 122 | the project goals (see README). 123 | 3. The pull request should work on all Python & Django versions listed in `README 124 | `_. 125 | Check https://travis-ci.org/bennylope/django-organizations/pull_requests 126 | and make sure that the tests pass for all supported Python versions. 127 | 4. Please try to follow `Django coding style 128 | `_. 129 | 5. Pull requests should include an amount of code and commits that are 130 | reasonable to review, are **logically grouped**, and based off clean feature 131 | branches. Commits should be identifiable to the original author by 132 | name/username and email. 133 | 134 | In a nutshell, changes must not break compatability with older installed 135 | versions of the software. You should be able to upgrade an installed version of 136 | Django Organizations by doing nothing more than upgrading the package and 137 | running `migrate`. 138 | 139 | **Bug fix pull requests should be made against the MASTER branch.** 140 | 141 | I am aiming to support each major Django version for `as long as it is 142 | supported 143 | `_. 144 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting started 3 | =============== 4 | 5 | Django-organizations allows you to add multi-user accounts to your application 6 | and tie permissions, events, and other data to organization level accounts. 7 | 8 | The core of the application consists of three models: 9 | 10 | * An **organization** model; the group object. This is what you would associate your own 11 | app's functionality with, e.g. subscriptions, repositories, projects, etc. 12 | * An **organization user**; a `through` model relating your **users** to your 13 | **organization**. It provides a convenient link for organization ownership 14 | (below) and also a way of storing organization/user specific information. 15 | * An **organization owner**. This model links to an **organization user** who 16 | has rights over the life and death of the organization. 17 | 18 | You can allow users to invite other users to their organizations and register 19 | with organizations as well. This functionality is provided through "backend" 20 | interfaces so that you can customize this code or use arbitrary user 21 | registration systems. 22 | 23 | Installation 24 | ============ 25 | 26 | First add the application to your Python path. The easiest way is to use 27 | `pip`:: 28 | 29 | pip install django-organizations 30 | 31 | Quickstart Configuration 32 | ======================== 33 | 34 | .. note:: 35 | 36 | If however you want to use single-table customized organization models and/or 37 | custom *organization user* models, it may be best to treat Django organizations 38 | as a library and *not* install it in your Django project. See the 39 | :ref:`cookbook-advanced` section. 40 | 41 | Ensure that you have a user system in place to connect to your organizations. 42 | 43 | To install the default models add `organizations` to `INSTALLED_APPS` in your 44 | settings file.:: 45 | 46 | INSTALLED_APPS = ( 47 | 'django.contrib.auth', 48 | 'organizations', 49 | ) 50 | 51 | This should work for the majority of cases, from either simple, out-of-the-box 52 | installations to custom organization models. 53 | 54 | URLs 55 | ---- 56 | 57 | If you plan on using the default URLs, hook the application URLs into your main 58 | application URL configuration in `urls.py`. If you plan on using the 59 | invitation/registration system, set your backend URLs, too:: 60 | 61 | from organizations.backends import invitation_backend 62 | 63 | urlpatterns = [ 64 | url(r'^accounts/', include('organizations.urls')), 65 | url(r'^invitations/', include(invitation_backend().get_urls())), 66 | ] 67 | 68 | Registration & invitation 69 | ------------------------- 70 | 71 | You can specify a different :ref:`invitation backend ` in 72 | your project settings, and the `invitation_backend` function will provide the 73 | URLs defined by that backend. You can do the same with the 74 | :ref:`registration backend `:: 75 | 76 | INVITATION_BACKEND = 'myapp.backends.MyInvitationBackend' 77 | REGISTRATION_BACKEND = 'myapp.backends.MyRegistrationBackend' 78 | 79 | Auto slug field 80 | --------------- 81 | 82 | Django-Organizations relies on `django-extensions 83 | `_ for the base 84 | `AutoSlugField 85 | `_. 86 | While django-extensions is great, this does require that every project install 87 | django-extensions for this one small feature. 88 | 89 | If you decide to use the default django-organization models by adding 90 | `organizations` to your INSTALLED_APPS, you can choose a different 91 | AutoSlugField. Just specify the full dotted path like so:: 92 | 93 | ORGS_SLUGFIELD = 'django_extensions.db.fields.AutoSlugField' 94 | 95 | While you can specify the source of this class, **its interfaces must be 96 | consistent**, including keyword arguments. Otherwise you will end up generating 97 | extraneous and possibly conflicting migrations in your own app. The SlugField 98 | must accept the `populate_from` keyword argument. 99 | 100 | Users and multi-account membership 101 | ================================== 102 | 103 | .. TODO add image showing how these are all related 104 | 105 | The key to these relationships is that while an `OrganizationUser` is 106 | associated with one and only one `Organization`, a `User` can be associated 107 | with multiple `OrganizationUsers` and hence multiple `Organizations`. 108 | 109 | .. note:: 110 | 111 | This means that the OrganizationUser class cannot be used as a UserProfile 112 | as that requires a one-to-one relationship with the User class. User 113 | profile information is better provided by a profile specific model. 114 | 115 | In your project you can associate accounts with things like subscriptions, 116 | documents, and other shared resources, all of which the account users can then 117 | access. 118 | 119 | Views and Mixins 120 | ================ 121 | 122 | Hooking the django-organizations URLs into your project provides a default set 123 | of views for accessing and updating organizations and organization membership. 124 | 125 | The included `class based views 126 | `_ are based on 127 | a set of mixins that allow the views to limit access by a user's relationship 128 | to an organization and that query the appropriate organization or user based on 129 | URL keywords. 130 | 131 | Implementing in your own project 132 | ================================ 133 | 134 | While django-organizations has some basic usability 'out-of-the-box', it's 135 | designed to be used as a foundation for project specific functionality. The 136 | :ref:`view mixins ` should provide base functionality from which to 137 | work for most projects, and the :ref:`cookbook` section provides detailed 138 | examples for various integration scenarios. 139 | -------------------------------------------------------------------------------- /src/organizations/locale/ja/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2023-12-19 23:43+0900\n" 12 | "PO-Revision-Date: 2022-11-28 11:08+0900\n" 13 | "Last-Translator: Sora Yanai \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: ja-JP\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | 21 | #: src/organizations/abstract.py:71 src/organizations/forms.py:177 22 | msgid "The name in all lowercase, suitable for URL identification" 23 | msgstr "URLの識別のための英数字(小文字)とハイフンで構成された名前" 24 | 25 | #: src/organizations/abstract.py:76 26 | msgid "organization" 27 | msgstr "組織" 28 | 29 | #: src/organizations/abstract.py:77 30 | #: src/organizations/templates/organizations/organization_list.html:4 31 | msgid "organizations" 32 | msgstr "組織" 33 | 34 | #: src/organizations/abstract.py:183 35 | msgid "organization user" 36 | msgstr "組織のユーザ" 37 | 38 | #: src/organizations/abstract.py:184 39 | msgid "organization users" 40 | msgstr "組織のユーザ" 41 | 42 | #: src/organizations/abstract.py:205 43 | msgid "" 44 | "Cannot delete organization owner before organization or transferring " 45 | "ownership." 46 | msgstr "組織化前または所有権移転前の組織所有者を削除できません。" 47 | 48 | #: src/organizations/abstract.py:230 49 | msgid "organization owner" 50 | msgstr "組織の所有者" 51 | 52 | #: src/organizations/abstract.py:231 53 | msgid "organization owners" 54 | msgstr "組織の所有者" 55 | 56 | #: src/organizations/backends/defaults.py:102 57 | #: src/organizations/backends/defaults.py:105 58 | msgid "Your URL may have expired." 59 | msgstr "URLの有効期限が切れている可能性があります。" 60 | 61 | #: src/organizations/backends/defaults.py:120 62 | msgid "Can't authenticate user" 63 | msgstr "ユーザを認証できません" 64 | 65 | #: src/organizations/backends/modeled.py:76 66 | msgid "This is not your invitation" 67 | msgstr "あなたの招待状ではありません" 68 | 69 | #: src/organizations/base.py:212 src/organizations/forms.py:174 70 | msgid "The name of the organization" 71 | msgstr "組織の名前" 72 | 73 | #: src/organizations/base.py:327 74 | msgid "" 75 | "The contact identifier for the invitee, email, phone number, social media " 76 | "handle, etc." 77 | msgstr "" 78 | "招待者の連絡先識別子、電子メール、電話番号、ソーシャル・メディア、ハンドル" 79 | "ネームなど。" 80 | 81 | #: src/organizations/forms.py:39 82 | msgid "Only the organization owner can change ownerhip" 83 | msgstr "組織の所有者だけが所有権を変更することができます" 84 | 85 | #: src/organizations/forms.py:57 86 | msgid "The organization owner must be an admin" 87 | msgstr "組織の所有者は管理者である必要があります" 88 | 89 | #: src/organizations/forms.py:115 90 | msgid "There is already an organization member with this email address!" 91 | msgstr "このメールアドレスを持つ組織のメンバーがすでにいます" 92 | 93 | #: src/organizations/forms.py:119 94 | msgid "This email address has been used multiple times." 95 | msgstr "このメールアドレスは複数回使用されています。" 96 | 97 | #: src/organizations/forms.py:131 98 | msgid "The email address for the account owner" 99 | msgstr "アカウント所有者のメールアドレス" 100 | 101 | #: src/organizations/templates/organizations/invitation_join.html:2 102 | msgid "Would you like to join?" 103 | msgstr "参加しますか?" 104 | 105 | #: src/organizations/templates/organizations/invitation_join.html:4 106 | msgid "Join" 107 | msgstr "参加" 108 | 109 | #: src/organizations/templates/organizations/login.html:10 110 | msgid "Forgotten your password?" 111 | msgstr "パスワードを忘れましたか?" 112 | 113 | #: src/organizations/templates/organizations/login.html:10 114 | msgid "Reset it" 115 | msgstr "リセット" 116 | 117 | #: src/organizations/templates/organizations/organization_confirm_delete.html:5 118 | msgid "Are you sure you want to delete this organization?" 119 | msgstr "この組織を本当に削除してよろしいですか?" 120 | 121 | #: src/organizations/templates/organizations/organization_detail.html:7 122 | #: src/organizations/templates/organizations/organizationuser_detail.html:11 123 | msgid "Edit" 124 | msgstr "編集" 125 | 126 | #: src/organizations/templates/organizations/organization_detail.html:8 127 | #: src/organizations/templates/organizations/organizationuser_detail.html:12 128 | msgid "Delete" 129 | msgstr "削除" 130 | 131 | #: src/organizations/templates/organizations/organization_detail.html:9 132 | #: src/organizations/templates/organizations/organizationuser_list.html:7 133 | msgid "Add a member" 134 | msgstr "メンバーを追加する" 135 | 136 | #: src/organizations/templates/organizations/organization_users.html:6 137 | msgid "Send reminder" 138 | msgstr "リマインダーを送信する" 139 | 140 | #: src/organizations/templates/organizations/organizationuser_detail.html:5 141 | #: src/organizations/templates/organizations/organizationuser_form.html:9 142 | msgid "This is you" 143 | msgstr "これはあなた自身です" 144 | 145 | #: src/organizations/templates/organizations/organizationuser_detail.html:7 146 | msgid "Name" 147 | msgstr "名前" 148 | 149 | #: src/organizations/templates/organizations/organizationuser_detail.html:8 150 | msgid "Email" 151 | msgstr "Email" 152 | 153 | #: src/organizations/templates/organizations/organizationuser_form.html:5 154 | msgid "Update your profile" 155 | msgstr "あなたのプロフィールを更新する" 156 | 157 | #: src/organizations/templates/organizations/register_success.html:2 158 | msgid "Thanks!" 159 | msgstr "ありがとうございました!" 160 | 161 | #: src/organizations/views/base.py:131 src/organizations/views/base.py:137 162 | msgid "User is already active" 163 | msgstr "ユーザーはすでにアクティブです" 164 | 165 | #: src/organizations/views/mixins.py:88 166 | msgid "Wrong organization" 167 | msgstr "間違った組織" 168 | 169 | #: src/organizations/views/mixins.py:103 170 | msgid "Sorry, admins only" 171 | msgstr "申し訳ありませんが、管理者のみです" 172 | 173 | #: src/organizations/views/mixins.py:118 174 | msgid "You are not the organization owner" 175 | msgstr "あなたは組織の所有者ではありません" 176 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.exceptions import PermissionDenied 3 | from django.http import HttpResponse 4 | from django.test import TestCase 5 | from django.test.client import RequestFactory 6 | from django.test.utils import override_settings 7 | 8 | from organizations.models import Organization 9 | from organizations.models import OrganizationUser 10 | from organizations.views.mixins import AdminRequiredMixin 11 | from organizations.views.mixins import MembershipRequiredMixin 12 | from organizations.views.mixins import OrganizationMixin 13 | from organizations.views.mixins import OrganizationUserMixin 14 | from organizations.views.mixins import OwnerRequiredMixin 15 | from tests.utils import request_factory_login 16 | 17 | 18 | class ViewStub: 19 | def __init__(self, **kwargs): 20 | self.kwargs = kwargs 21 | 22 | def get_context_data(self, **kwargs): 23 | return kwargs 24 | 25 | def dispatch(self, request, *args, **kwargs): 26 | return HttpResponse("Success") 27 | 28 | 29 | class OrgView(OrganizationMixin, ViewStub): 30 | """A testing view class""" 31 | 32 | pass 33 | 34 | 35 | class UserView(OrganizationUserMixin, ViewStub): 36 | """A testing view class""" 37 | 38 | pass 39 | 40 | 41 | @override_settings(USE_TZ=True) 42 | class ObjectMixinTests(TestCase): 43 | fixtures = ["users.json", "orgs.json"] 44 | 45 | def setUp(self): 46 | self.foo = Organization.objects.get(name="Foo Fighters") 47 | self.dave = OrganizationUser.objects.get( 48 | user__username="dave", organization=self.foo 49 | ) 50 | 51 | def test_get_org_object(self): 52 | view = OrgView(organization_pk=self.foo.pk) 53 | self.assertEqual(view.get_object(), self.foo) 54 | 55 | def test_get_user_object(self): 56 | view = UserView(organization_pk=self.foo.pk, user_pk=self.dave.pk) 57 | self.assertEqual(view.get_object(), self.dave) 58 | self.assertEqual(view.get_organization(), self.foo) 59 | 60 | def test_get_model(self): 61 | """Ensure that the method returns the class object""" 62 | self.assertEqual(Organization, OrganizationMixin().get_org_model()) 63 | self.assertEqual(Organization, OrganizationUserMixin().get_org_model()) 64 | self.assertEqual(OrganizationUser, OrganizationUserMixin().get_user_model()) 65 | 66 | 67 | @override_settings(USE_TZ=True) 68 | class AccessMixinTests(TestCase): 69 | fixtures = ["users.json", "orgs.json"] 70 | 71 | def setUp(self): 72 | self.nirvana = Organization.objects.get(name="Nirvana") 73 | self.kurt = User.objects.get(username="kurt") 74 | self.krist = User.objects.get(username="krist") 75 | self.dave = User.objects.get(username="dave") 76 | self.dummy = User.objects.create_user( 77 | "dummy", email="dummy@example.com", password="test" 78 | ) 79 | self.factory = RequestFactory() 80 | self.kurt_request = request_factory_login(self.factory, self.kurt) 81 | self.krist_request = request_factory_login(self.factory, self.krist) 82 | self.dave_request = request_factory_login(self.factory, self.dave) 83 | self.dummy_request = request_factory_login(self.factory, self.dummy) 84 | 85 | def test_member_access(self): 86 | class MemberView(MembershipRequiredMixin, OrgView): 87 | pass 88 | 89 | self.assertEqual( 90 | 200, 91 | MemberView() 92 | .dispatch(self.kurt_request, organization_pk=self.nirvana.pk) 93 | .status_code, 94 | ) 95 | self.assertEqual( 96 | 200, 97 | MemberView() 98 | .dispatch(self.krist_request, organization_pk=self.nirvana.pk) 99 | .status_code, 100 | ) 101 | self.assertEqual( 102 | 200, 103 | MemberView() 104 | .dispatch(self.dave_request, organization_pk=self.nirvana.pk) 105 | .status_code, 106 | ) 107 | with self.assertRaises(PermissionDenied): 108 | MemberView().dispatch(self.dummy_request, organization_pk=self.nirvana.pk) 109 | 110 | def test_admin_access(self): 111 | class AdminView(AdminRequiredMixin, OrgView): 112 | pass 113 | 114 | self.assertEqual( 115 | 200, 116 | AdminView() 117 | .dispatch(self.kurt_request, organization_pk=self.nirvana.pk) 118 | .status_code, 119 | ) 120 | self.assertEqual( 121 | 200, 122 | AdminView() 123 | .dispatch(self.krist_request, organization_pk=self.nirvana.pk) 124 | .status_code, 125 | ) 126 | # Superuser 127 | self.assertEqual( 128 | 200, 129 | AdminView() 130 | .dispatch(self.dave_request, organization_pk=self.nirvana.pk) 131 | .status_code, 132 | ) 133 | with self.assertRaises(PermissionDenied): 134 | AdminView().dispatch(self.dummy_request, organization_pk=self.nirvana.pk) 135 | 136 | def test_owner_access(self): 137 | class OwnerView(OwnerRequiredMixin, OrgView): 138 | pass 139 | 140 | self.assertEqual( 141 | 200, 142 | OwnerView() 143 | .dispatch(self.kurt_request, organization_pk=self.nirvana.pk) 144 | .status_code, 145 | ) 146 | with self.assertRaises(PermissionDenied): 147 | OwnerView().dispatch(self.krist_request, organization_pk=self.nirvana.pk) 148 | # Superuser 149 | self.assertEqual( 150 | 200, 151 | OwnerView() 152 | .dispatch(self.dave_request, organization_pk=self.nirvana.pk) 153 | .status_code, 154 | ) 155 | with self.assertRaises(PermissionDenied): 156 | OwnerView().dispatch(self.dummy_request, organization_pk=self.nirvana.pk) 157 | -------------------------------------------------------------------------------- /tests/backends/test_model_based_backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the model based invitation backend 3 | """ 4 | 5 | from django.contrib.auth.models import User 6 | from django.core import mail 7 | 8 | import pytest 9 | 10 | from organizations.backends.defaults import InvitationBackend 11 | from organizations.backends.modeled import ModelInvitation 12 | from organizations.base import OrganizationInvitationBase 13 | from organizations.utils import create_organization 14 | from test_abstract.models import CustomOrganization 15 | from test_accounts.models import Account 16 | from test_accounts.models import AccountInvitation 17 | from test_vendors.models import Vendor 18 | 19 | 20 | @pytest.fixture 21 | def account_user(): 22 | yield User.objects.create(username="AccountUser", email="akjdkj@kjdk.com") 23 | 24 | 25 | @pytest.fixture 26 | def account_account(account_user): 27 | vendor = create_organization(account_user, "Acme", org_model=Account) 28 | yield vendor 29 | 30 | 31 | @pytest.fixture 32 | def invitee_user(): 33 | yield User.objects.create_user( 34 | "newmember", email="jd@123.com", password="password123" 35 | ) 36 | 37 | 38 | @pytest.fixture 39 | def invitation_backend(): 40 | yield ModelInvitation(org_model=Account) 41 | 42 | 43 | @pytest.fixture 44 | def email_invitation(invitation_backend, account_account, account_user, invitee_user): 45 | yield invitation_backend.invite_by_email( 46 | invitee_user.email, user=account_user, organization=account_account 47 | ) 48 | 49 | 50 | class TestCustomModelBackend: 51 | """ 52 | The default backend should provide the same basic functionality 53 | irrespective of the organization model. 54 | """ 55 | 56 | def test_activate_orgs_vendor(self, account_user): 57 | """Ensure no errors raised because correct relation name used""" 58 | backend = InvitationBackend(org_model=Vendor) 59 | backend.activate_organizations(account_user) 60 | 61 | def test_activate_orgs_abstract(self, account_user): 62 | backend = InvitationBackend(org_model=CustomOrganization) 63 | backend.activate_organizations(account_user) 64 | 65 | def test_invitation_str(self, email_invitation, invitee_user, account_account): 66 | assert str(email_invitation) == "{}: {}".format( 67 | account_account.name, invitee_user.email 68 | ) 69 | 70 | 71 | class TestInvitationModelBackend: 72 | """ 73 | Tests the backend using InvitationModels 74 | 75 | pytest only! 76 | """ 77 | 78 | def test_invite_returns_invitation(self, account_user, account_account): 79 | backend = ModelInvitation(org_model=Account) 80 | invitation = backend.invite_by_email( 81 | "bob@newuser.com", user=account_user, organization=account_account 82 | ) 83 | assert isinstance(invitation, OrganizationInvitationBase) 84 | 85 | def test_account_invitation_is_org_invitation_base(self, email_invitation): 86 | assert isinstance(email_invitation, OrganizationInvitationBase) 87 | 88 | def test_send_invitation_anon_user( 89 | self, invitation_backend, account_user, account_account, client 90 | ): 91 | """Integration test with anon user""" 92 | 93 | outbox_count = len(mail.outbox) 94 | invitation = invitation_backend.invite_by_email( 95 | "bob@newuser.com", user=account_user, organization=account_account 96 | ) 97 | 98 | assert len(mail.outbox) > outbox_count 99 | 100 | response = client.get(invitation.get_absolute_url()) 101 | assert response.status_code == 200 102 | 103 | def test_new_user_accepts_invitation( 104 | self, invitation_backend, account_user, account_account, client 105 | ): 106 | invitation = invitation_backend.invite_by_email( 107 | "heehaw@hello.com", user=account_user, organization=account_account 108 | ) 109 | response = client.post( 110 | invitation.get_absolute_url(), 111 | data={ 112 | "username": "heehaw", 113 | "email": "heehaw@hello.com", 114 | "password1": "aksjdf83k1j!!", 115 | "password2": "aksjdf83k1j!!", 116 | }, 117 | ) 118 | assert response.status_code == 302 119 | 120 | def test_that_the_inviting_user_cannot_access( 121 | self, email_invitation, invitee_user, account_user, account_account, client 122 | ): 123 | client.force_login(account_user) 124 | response = client.get(email_invitation.get_absolute_url()) 125 | assert response.status_code == 403 126 | 127 | def test_existing_user_is_not_linked_as_invitee_at_invitation_time( 128 | self, email_invitation, invitee_user, account_user, account_account, client 129 | ): 130 | assert email_invitation.invitee is None 131 | assert email_invitation.invitee_identifier == invitee_user.email 132 | 133 | def test_existing_user_can_view_the_invitation( 134 | self, email_invitation, invitee_user, client 135 | ): 136 | client.force_login(invitee_user) 137 | response = client.get(email_invitation.get_absolute_url()) 138 | assert response.status_code == 200 139 | 140 | def test_existing_user_activating_invitation( 141 | self, email_invitation, invitee_user, client 142 | ): 143 | # Need an org with an invite to a user who is logged in 144 | client.force_login(invitee_user) 145 | client.post(email_invitation.get_absolute_url()) 146 | email_invitation.refresh_from_db() 147 | assert email_invitation.invitee == invitee_user 148 | 149 | def test_accessing_invitation_once_it_has_been_used( 150 | self, invitee_user, account_user, account_account, client 151 | ): 152 | invitation = AccountInvitation.objects.create( 153 | invitee_identifier=invitee_user.email, 154 | invitee=invitee_user, 155 | invited_by=account_user, 156 | organization=account_account, 157 | ) 158 | client.force_login(invitee_user) 159 | response = client.get(invitation.get_absolute_url()) 160 | assert response.status_code == 302 161 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-organizations.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-organizations.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-organizations" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-organizations" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /src/organizations/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-12-13 22:05+0100\n" 11 | "PO-Revision-Date: 2022-12-13 22:23+0100\n" 12 | "Last-Translator: ravi0lii \n" 13 | "Language-Team: \n" 14 | "Language: de\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: organizations/abstract.py:83 organizations/forms.py:179 21 | msgid "The name in all lowercase, suitable for URL identification" 22 | msgstr "Der Name in Kleinbuchstaben, geeignet für die URL-Identifikation" 23 | 24 | #: organizations/abstract.py:88 25 | msgid "organization" 26 | msgstr "Organisation" 27 | 28 | #: organizations/abstract.py:89 29 | #: organizations/templates/organizations/organization_list.html:4 30 | msgid "organizations" 31 | msgstr "Organisationen" 32 | 33 | #: organizations/abstract.py:195 34 | msgid "organization user" 35 | msgstr "Organisationsmitglied" 36 | 37 | #: organizations/abstract.py:196 38 | msgid "organization users" 39 | msgstr "Organisationsmitglieder" 40 | 41 | #: organizations/abstract.py:217 42 | msgid "" 43 | "Cannot delete organization owner before organization or transferring " 44 | "ownership." 45 | msgstr "" 46 | "Der Eigentümer der Organisation kann nicht gelöscht werden, bevor die " 47 | "Organisation oder der Eigentümer nicht übertragen wird." 48 | 49 | #: organizations/abstract.py:242 50 | msgid "organization owner" 51 | msgstr "Organisationseigentümer" 52 | 53 | #: organizations/abstract.py:243 54 | msgid "organization owners" 55 | msgstr "Organisationseigentümer" 56 | 57 | #: organizations/backends/defaults.py:104 58 | #: organizations/backends/defaults.py:107 59 | msgid "Your URL may have expired." 60 | msgstr "Deine URL ist möglicherweise abgelaufen." 61 | 62 | #: organizations/backends/modeled.py:77 63 | msgid "This is not your invitation" 64 | msgstr "Das ist nicht deine Einladung" 65 | 66 | #: organizations/base.py:216 organizations/forms.py:176 67 | msgid "The name of the organization" 68 | msgstr "Der Name der Organisation" 69 | 70 | #: organizations/base.py:331 71 | msgid "" 72 | "The contact identifier for the invitee, email, phone number, social media " 73 | "handle, etc." 74 | msgstr "" 75 | "Die Kontakt-Identifikation für den Eingeladenen, wie E-Mail, " 76 | "Telefonnummer, Social-Media-Handle etc." 77 | 78 | #: organizations/forms.py:41 79 | msgid "Only the organization owner can change ownerhip" 80 | msgstr "Nur der Eigentümer der Organisation kann den Eigentümer ändern" 81 | 82 | #: organizations/forms.py:59 83 | msgid "The organization owner must be an admin" 84 | msgstr "Der Eigentümer der Organisation muss ein Administrator sein" 85 | 86 | #: organizations/forms.py:117 87 | msgid "There is already an organization member with this email address!" 88 | msgstr "" 89 | "Es gibt bereits ein Mitglied der Organisation mit dieser E-Mail Adresse!" 90 | 91 | #: organizations/forms.py:121 92 | msgid "This email address has been used multiple times." 93 | msgstr "Die E-Mail Adresse wurde mehrmals genutzt." 94 | 95 | #: organizations/forms.py:133 96 | msgid "The email address for the account owner" 97 | msgstr "Die E-Mail Adresse des Eigentümers des Kontos" 98 | 99 | #: organizations/templates/organizations/invitation_join.html:2 100 | msgid "Would you like to join?" 101 | msgstr "Willst du beitreten?" 102 | 103 | #: organizations/templates/organizations/invitation_join.html:4 104 | msgid "Join" 105 | msgstr "Beitreten" 106 | 107 | #: organizations/templates/organizations/login.html:10 108 | msgid "Forgotten your password?" 109 | msgstr "Passwort vergessen?" 110 | 111 | #: organizations/templates/organizations/login.html:10 112 | msgid "Reset it" 113 | msgstr "Zurücksetzen" 114 | 115 | #: organizations/templates/organizations/organization_confirm_delete.html:5 116 | msgid "Are you sure you want to delete this organization?" 117 | msgstr "Bist du dir sicher, dass du die Organisation löschen willst?" 118 | 119 | #: organizations/templates/organizations/organization_detail.html:7 120 | #: organizations/templates/organizations/organizationuser_detail.html:11 121 | msgid "Edit" 122 | msgstr "Bearbeiten" 123 | 124 | #: organizations/templates/organizations/organization_detail.html:8 125 | #: organizations/templates/organizations/organizationuser_detail.html:12 126 | msgid "Delete" 127 | msgstr "Löschen" 128 | 129 | #: organizations/templates/organizations/organization_detail.html:9 130 | #: organizations/templates/organizations/organizationuser_list.html:7 131 | msgid "Add a member" 132 | msgstr "Mitglied hinzufügen" 133 | 134 | #: organizations/templates/organizations/organization_users.html:6 135 | msgid "Send reminder" 136 | msgstr "Erinnerung senden" 137 | 138 | #: organizations/templates/organizations/organizationuser_detail.html:5 139 | #: organizations/templates/organizations/organizationuser_form.html:9 140 | msgid "This is you" 141 | msgstr "Das bist du" 142 | 143 | #: organizations/templates/organizations/organizationuser_detail.html:7 144 | msgid "Name" 145 | msgstr "Name" 146 | 147 | #: organizations/templates/organizations/organizationuser_detail.html:8 148 | msgid "Email" 149 | msgstr "E-Mail" 150 | 151 | #: organizations/templates/organizations/organizationuser_form.html:5 152 | msgid "Update your profile" 153 | msgstr "Aktualisiere dein Profil" 154 | 155 | #: organizations/templates/organizations/register_success.html:2 156 | msgid "Thanks!" 157 | msgstr "Danke!" 158 | 159 | #: organizations/views/base.py:133 organizations/views/base.py:139 160 | msgid "User is already active" 161 | msgstr "Nutzer ist bereits aktiv" 162 | 163 | #: organizations/views/mixins.py:90 164 | msgid "Wrong organization" 165 | msgstr "Falsche Organisation" 166 | 167 | #: organizations/views/mixins.py:105 168 | msgid "Sorry, admins only" 169 | msgstr "Entschuldigung, nur Administratoren sind erlaubt" 170 | 171 | #: organizations/views/mixins.py:120 172 | msgid "You are not the organization owner" 173 | msgstr "Du bist nicht der Eigentümer der Organisation" 174 | -------------------------------------------------------------------------------- /example/conf/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Django settings for conf project. 4 | 5 | settings_dir = os.path.dirname(__file__) 6 | PROJECT_ROOT = os.path.abspath(os.path.dirname(settings_dir)) 7 | 8 | DEBUG = True 9 | TEMPLATE_DEBUG = DEBUG 10 | 11 | ADMINS = ( 12 | # ('Your Name', 'your_email@example.com'), 13 | ) 14 | 15 | MANAGERS = ADMINS 16 | 17 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.sqlite"}} 18 | 19 | # Local time zone for this installation. Choices can be found here: 20 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 21 | # although not all choices may be available on all operating systems. 22 | # On Unix systems, a value of None will cause Django to use the same 23 | # timezone as the operating system. 24 | # If running in a Windows environment this must be set to the same as your 25 | # system time zone. 26 | TIME_ZONE = "America/New_York" 27 | 28 | # Language code for this installation. All choices can be found here: 29 | # http://www.i18nguy.com/unicode/language-identifiers.html 30 | LANGUAGE_CODE = "en-us" 31 | 32 | SITE_ID = 1 33 | 34 | # If you set this to False, Django will make some optimizations so as not 35 | # to load the internationalization machinery. 36 | USE_I18N = True 37 | 38 | # If you set this to False, Django will not format dates, numbers and 39 | # calendars according to the current locale. 40 | USE_L10N = True 41 | 42 | # If you set this to False, Django will not use timezone-aware datetimes. 43 | USE_TZ = True 44 | 45 | # Absolute filesystem path to the directory that will hold user-uploaded files. 46 | # Example: "/home/media/media.lawrence.com/media/" 47 | MEDIA_ROOT = "" 48 | 49 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 50 | # trailing slash. 51 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 52 | MEDIA_URL = "" 53 | 54 | # Absolute path to the directory static files should be collected to. 55 | # Don't put anything in this directory yourself; store your static files 56 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 57 | # Example: "/home/media/media.lawrence.com/static/" 58 | STATIC_ROOT = "" 59 | 60 | # URL prefix for static files. 61 | # Example: "http://media.lawrence.com/static/" 62 | STATIC_URL = "/static/" 63 | 64 | # Additional locations of static files 65 | STATICFILES_DIRS = ( 66 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 67 | # Always use forward slashes, even on Windows. 68 | # Don't forget to use absolute paths, not relative paths. 69 | ) 70 | 71 | # List of finder classes that know how to find static files in 72 | # various locations. 73 | STATICFILES_FINDERS = ( 74 | "django.contrib.staticfiles.finders.FileSystemFinder", 75 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 76 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 77 | ) 78 | 79 | # Make this unique, and don't share it with anybody. 80 | SECRET_KEY = "7@m$nx@q%-$la^fy_(-rhxtvoxk118hrprg=q86f(@k*6^^vf8" 81 | 82 | 83 | MIDDLEWARE = [ 84 | "django.middleware.common.CommonMiddleware", 85 | "django.contrib.sessions.middleware.SessionMiddleware", 86 | "django.middleware.csrf.CsrfViewMiddleware", 87 | "django.contrib.auth.middleware.AuthenticationMiddleware", 88 | "django.contrib.messages.middleware.MessageMiddleware", 89 | # Uncomment the next line for simple clickjacking protection: 90 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 91 | ] 92 | 93 | ROOT_URLCONF = "conf.urls" 94 | 95 | # Python dotted path to the WSGI application used by Django's runserver. 96 | WSGI_APPLICATION = "conf.wsgi.application" 97 | 98 | TEMPLATES = [ 99 | { 100 | "BACKEND": "django.template.backends.django.DjangoTemplates", 101 | "DIRS": [os.path.join(PROJECT_ROOT, "templates/")], 102 | "APP_DIRS": True, 103 | "OPTIONS": { 104 | "context_processors": [ 105 | "django.contrib.auth.context_processors.auth", 106 | "django.template.context_processors.debug", 107 | "django.template.context_processors.request", 108 | "django.template.context_processors.media", 109 | "django.template.context_processors.static", 110 | "django.contrib.messages.context_processors.messages", 111 | ], 112 | "debug": DEBUG, 113 | }, 114 | } 115 | ] 116 | 117 | INSTALLED_APPS = [ 118 | "django.contrib.auth", 119 | "django.contrib.contenttypes", 120 | "django.contrib.sessions", 121 | "django.contrib.sites", 122 | "django.contrib.messages", 123 | "django.contrib.staticfiles", 124 | "django.contrib.admin", 125 | "organizations", 126 | "accounts", 127 | "vendors", 128 | ] 129 | 130 | 131 | # MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',) 132 | # INSTALLED_APPS += ('debug_toolbar',) 133 | INTERNAL_IPS = ("127.0.0.1",) 134 | 135 | DEBUG_TOOLBAR_CONFIG = {"INTERCEPT_REDIRECTS": False, "TAG": "body"} 136 | 137 | DEBUG_TOOLBAR_PANELS = ( 138 | "debug_toolbar.panels.version.VersionDebugPanel", 139 | "debug_toolbar.panels.timer.TimerDebugPanel", 140 | "debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel", 141 | "debug_toolbar.panels.headers.HeaderDebugPanel", 142 | "debug_toolbar.panels.request_vars.RequestVarsDebugPanel", 143 | "debug_toolbar.panels.template.TemplateDebugPanel", 144 | "debug_toolbar.panels.sql.SQLDebugPanel", 145 | "debug_toolbar.panels.signals.SignalDebugPanel", 146 | "debug_toolbar.panels.logger.LoggingPanel", 147 | ) 148 | 149 | 150 | # A sample logging configuration. The only tangible logging 151 | # performed by this configuration is to send an email to 152 | # the site admins on every HTTP 500 error when DEBUG=False. 153 | # See http://docs.djangoproject.com/en/dev/topics/logging for 154 | # more details on how to customize your logging configuration. 155 | LOGGING = { 156 | "version": 1, 157 | "disable_existing_loggers": False, 158 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 159 | "handlers": { 160 | "mail_admins": { 161 | "level": "ERROR", 162 | "filters": ["require_debug_false"], 163 | "class": "django.utils.log.AdminEmailHandler", 164 | } 165 | }, 166 | "loggers": { 167 | "django.request": { 168 | "handlers": ["mail_admins"], 169 | "level": "ERROR", 170 | "propagate": True, 171 | } 172 | }, 173 | } 174 | 175 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 176 | -------------------------------------------------------------------------------- /src/organizations/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2017-12-05 00:17 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations 7 | from django.db import models 8 | 9 | import organizations.base 10 | import organizations.fields 11 | 12 | 13 | class Migration(migrations.Migration): 14 | initial = True 15 | 16 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="Organization", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ( 32 | "name", 33 | models.CharField( 34 | help_text="The name of the organization", max_length=200 35 | ), 36 | ), 37 | ("is_active", models.BooleanField(default=True)), 38 | ( 39 | "created", 40 | organizations.fields.AutoCreatedField( 41 | default=django.utils.timezone.now, editable=False 42 | ), 43 | ), 44 | ( 45 | "modified", 46 | organizations.fields.AutoLastModifiedField( 47 | default=django.utils.timezone.now, editable=False 48 | ), 49 | ), 50 | ( 51 | "slug", 52 | organizations.fields.SlugField( 53 | editable=True, 54 | help_text=( 55 | "The name in all lowercase, suitable for URL identification" 56 | ), 57 | max_length=200, 58 | populate_from="name", 59 | unique=True, 60 | ), 61 | ), 62 | ], 63 | options={ 64 | "verbose_name": "organization", 65 | "verbose_name_plural": "organizations", 66 | "ordering": ["name"], 67 | "abstract": False, 68 | }, 69 | ), 70 | migrations.CreateModel( 71 | name="OrganizationOwner", 72 | fields=[ 73 | ( 74 | "id", 75 | models.AutoField( 76 | auto_created=True, 77 | primary_key=True, 78 | serialize=False, 79 | verbose_name="ID", 80 | ), 81 | ), 82 | ( 83 | "created", 84 | organizations.fields.AutoCreatedField( 85 | default=django.utils.timezone.now, editable=False 86 | ), 87 | ), 88 | ( 89 | "modified", 90 | organizations.fields.AutoLastModifiedField( 91 | default=django.utils.timezone.now, editable=False 92 | ), 93 | ), 94 | ( 95 | "organization", 96 | models.OneToOneField( 97 | on_delete=django.db.models.deletion.CASCADE, 98 | related_name="owner", 99 | to="organizations.Organization", 100 | ), 101 | ), 102 | ], 103 | options={ 104 | "verbose_name": "organization owner", 105 | "verbose_name_plural": "organization owners", 106 | "abstract": False, 107 | }, 108 | ), 109 | migrations.CreateModel( 110 | name="OrganizationUser", 111 | fields=[ 112 | ( 113 | "id", 114 | models.AutoField( 115 | auto_created=True, 116 | primary_key=True, 117 | serialize=False, 118 | verbose_name="ID", 119 | ), 120 | ), 121 | ( 122 | "created", 123 | organizations.fields.AutoCreatedField( 124 | default=django.utils.timezone.now, editable=False 125 | ), 126 | ), 127 | ( 128 | "modified", 129 | organizations.fields.AutoLastModifiedField( 130 | default=django.utils.timezone.now, editable=False 131 | ), 132 | ), 133 | ("is_admin", models.BooleanField(default=False)), 134 | ( 135 | "organization", 136 | models.ForeignKey( 137 | on_delete=django.db.models.deletion.CASCADE, 138 | related_name="organization_users", 139 | to="organizations.Organization", 140 | ), 141 | ), 142 | ( 143 | "user", 144 | models.ForeignKey( 145 | on_delete=django.db.models.deletion.CASCADE, 146 | related_name="organizations_organizationuser", 147 | to=settings.AUTH_USER_MODEL, 148 | ), 149 | ), 150 | ], 151 | options={ 152 | "verbose_name": "organization user", 153 | "verbose_name_plural": "organization users", 154 | "ordering": ["organization", "user"], 155 | "abstract": False, 156 | }, 157 | ), 158 | migrations.AddField( 159 | model_name="organizationowner", 160 | name="organization_user", 161 | field=models.OneToOneField( 162 | on_delete=django.db.models.deletion.CASCADE, 163 | to="organizations.OrganizationUser", 164 | ), 165 | ), 166 | migrations.AddField( 167 | model_name="organization", 168 | name="users", 169 | field=models.ManyToManyField( 170 | related_name="organizations_organization", 171 | through="organizations.OrganizationUser", 172 | to=settings.AUTH_USER_MODEL, 173 | ), 174 | ), 175 | migrations.AlterUniqueTogether( 176 | name="organizationuser", unique_together={("user", "organization")} 177 | ), 178 | ] 179 | -------------------------------------------------------------------------------- /src/organizations/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.sites.shortcuts import get_current_site 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from organizations.backends import invitation_backend 7 | from organizations.models import Organization 8 | from organizations.models import OrganizationUser 9 | from organizations.utils import create_organization 10 | 11 | 12 | class OrganizationForm(forms.ModelForm): 13 | """Form class for updating Organizations""" 14 | 15 | owner = forms.ModelChoiceField(OrganizationUser.objects.all()) 16 | 17 | def __init__(self, request, *args, **kwargs): 18 | self.request = request 19 | super().__init__(*args, **kwargs) 20 | self.fields["owner"].queryset = self.instance.organization_users.filter( 21 | is_admin=True, user__is_active=True 22 | ) 23 | self.fields["owner"].initial = self.instance.owner.organization_user 24 | 25 | class Meta: 26 | model = Organization 27 | exclude = ("users", "is_active") 28 | 29 | def save(self, commit=True): 30 | if self.instance.owner.organization_user != self.cleaned_data["owner"]: 31 | self.instance.change_owner(self.cleaned_data["owner"]) 32 | return super().save(commit=commit) 33 | 34 | def clean_owner(self): 35 | owner = self.cleaned_data["owner"] 36 | if owner != self.instance.owner.organization_user: 37 | if self.request.user != self.instance.owner.organization_user.user: 38 | raise forms.ValidationError( 39 | _("Only the organization owner can change ownerhip") 40 | ) 41 | return owner 42 | 43 | 44 | class OrganizationUserForm(forms.ModelForm): 45 | """Form class for updating OrganizationUsers""" 46 | 47 | class Meta: 48 | model = OrganizationUser 49 | exclude = ("organization", "user") 50 | 51 | def clean_is_admin(self): 52 | is_admin = self.cleaned_data["is_admin"] 53 | if ( 54 | self.instance.organization.owner.organization_user == self.instance 55 | and not is_admin 56 | ): 57 | raise forms.ValidationError(_("The organization owner must be an admin")) 58 | return is_admin 59 | 60 | 61 | class OrganizationUserAddForm(forms.ModelForm): 62 | """Form class for adding OrganizationUsers to an existing Organization""" 63 | 64 | email = forms.EmailField(max_length=75) 65 | 66 | def __init__(self, request, organization, *args, **kwargs): 67 | self.request = request 68 | self.organization = organization 69 | super().__init__(*args, **kwargs) 70 | 71 | class Meta: 72 | model = OrganizationUser 73 | exclude = ("user", "organization") 74 | 75 | def save(self, *args, **kwargs): 76 | """ 77 | The save method should create a new OrganizationUser linking the User 78 | matching the provided email address. If not matching User is found it 79 | should kick off the registration process. It needs to create a User in 80 | order to link it to the Organization. 81 | """ 82 | try: 83 | user = get_user_model().objects.get( 84 | email__iexact=self.cleaned_data["email"] 85 | ) 86 | except get_user_model().DoesNotExist: 87 | user = invitation_backend().invite_by_email( 88 | self.cleaned_data["email"], 89 | **{ 90 | "domain": get_current_site(self.request), 91 | "organization": self.organization, 92 | "sender": self.request.user, 93 | }, 94 | ) 95 | # Send a notification email to this user to inform them that they 96 | # have been added to a new organization. 97 | invitation_backend().send_notification( 98 | user, 99 | **{ 100 | "domain": get_current_site(self.request), 101 | "organization": self.organization, 102 | "sender": self.request.user, 103 | }, 104 | ) 105 | return OrganizationUser.objects.create( 106 | user=user, 107 | organization=self.organization, 108 | is_admin=self.cleaned_data["is_admin"], 109 | ) 110 | 111 | def clean_email(self): 112 | email = self.cleaned_data["email"] 113 | if self.organization.users.filter(email__iexact=email).exists(): 114 | raise forms.ValidationError( 115 | _("There is already an organization " "member with this email address!") 116 | ) 117 | if get_user_model().objects.filter(email__iexact=email).count() > 1: 118 | raise forms.ValidationError( 119 | _("This email address has been used multiple times.") 120 | ) 121 | return email 122 | 123 | 124 | class OrganizationAddForm(forms.ModelForm): 125 | """ 126 | Form class for creating a new organization, complete with new owner, including a 127 | User instance, OrganizationUser instance, and OrganizationOwner instance. 128 | """ 129 | 130 | email = forms.EmailField( 131 | max_length=75, help_text=_("The email address for the account owner") 132 | ) 133 | 134 | def __init__(self, request, *args, **kwargs): 135 | self.request = request 136 | super().__init__(*args, **kwargs) 137 | 138 | class Meta: 139 | model = Organization 140 | exclude = ("users", "is_active") 141 | 142 | def save(self, **kwargs): 143 | """ 144 | Create the organization, then get the user, then make the owner. 145 | """ 146 | is_active = True 147 | try: 148 | user = get_user_model().objects.get(email=self.cleaned_data["email"]) 149 | except get_user_model().DoesNotExist: 150 | # TODO(bennylope): look into hooks for alt. registration systems here 151 | user = invitation_backend().invite_by_email( 152 | self.cleaned_data["email"], 153 | **{ 154 | "domain": get_current_site(self.request), 155 | "organization": self.cleaned_data["name"], 156 | "sender": self.request.user, 157 | "created": True, 158 | }, 159 | ) 160 | is_active = False 161 | return create_organization( 162 | user, 163 | self.cleaned_data["name"], 164 | self.cleaned_data["slug"], 165 | is_active=is_active, 166 | ) 167 | 168 | 169 | class SignUpForm(forms.Form): 170 | """ 171 | Form class for signing up a new user and new account. 172 | """ 173 | 174 | name = forms.CharField(max_length=50, help_text=_("The name of the organization")) 175 | slug = forms.SlugField( 176 | max_length=50, 177 | help_text=_("The name in all lowercase, suitable for URL identification"), 178 | ) 179 | email = forms.EmailField() 180 | -------------------------------------------------------------------------------- /docs/reference/backends.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Invitation and Registration Backends 3 | ==================================== 4 | 5 | The purpose of the backends is to provide scaffolding for adding and managing 6 | users and organizations. **The scope is limited to the basics of adding new 7 | users and creating new organizations**. 8 | 9 | While the default backends should suffice for basic implementations, the 10 | backends are designed to be easily extended for your specific project needs. If 11 | you make use of a profile model or a user model other than `auth.User` you 12 | should extend the relevant backends for your own project. If you've used 13 | custom URL names then you'll also want to extend the backends to use your own 14 | success URLs. 15 | 16 | You do not have to implement these backends to use django-organizations, but 17 | they will make user management within accounts easier. 18 | 19 | The two default backends share a common structure and interface. This includes 20 | methods for sending emails, generating URLs, and template references. 21 | 22 | The backend URLs will need to be configured to allow for registration and/or 23 | user activation. You can add these by referring to the backend's `get_urls` 24 | method::: 25 | 26 | from organizations.backends import invitation_backend 27 | 28 | urlpatterns = [ 29 | url(r'^invitations/', include(invitation_backend().get_urls())), 30 | ] 31 | 32 | .. _registration-backend: 33 | 34 | Registration Backend 35 | ==================== 36 | 37 | The registration backend is used for creating new users with new organizations, 38 | e.g. new user sign up. 39 | 40 | Attributes 41 | ---------- 42 | 43 | .. attribute:: RegistrationBackend.activation_subject 44 | 45 | Template path for the activation email subject. Default:: 46 | 47 | invitation_subject = 'organizations/email/activation_subject.txt' 48 | 49 | .. attribute:: RegistrationBackend.activation_body 50 | 51 | Template path for the activation email body. Default:: 52 | 53 | invitation_body = 'organizations/email/activation_body.html' 54 | 55 | .. attribute:: RegistrationBackend.reminder_subject 56 | 57 | Template path for the reminder email subject. Default:: 58 | 59 | reminder_subject = 'organizations/email/reminder_subject.txt' 60 | 61 | .. attribute:: RegistrationBackend.reminder_body 62 | 63 | Template path for the reminder email body. Default:: 64 | 65 | reminder_body = 'organizations/email/reminder_body.html' 66 | 67 | .. attribute:: RegistrationBackend.form_class 68 | 69 | Form class which should be used for activating a user account when 70 | registering. Default:: 71 | 72 | form_class = UserRegistrationForm 73 | 74 | .. _invitation-backend: 75 | 76 | Invitation backend 77 | ================== 78 | 79 | The invitation backend is used for adding new users to an *existing 80 | organization*. 81 | 82 | Attributes 83 | ---------- 84 | 85 | .. attribute:: InvitationBackend.invitation_subject 86 | 87 | Template path for the invitation email subject. Default:: 88 | 89 | invitation_subject = 'organizations/email/invitation_subject.txt' 90 | 91 | .. attribute:: InvitationBackend.invitation_body 92 | 93 | Template path for the invitation email body. Default:: 94 | 95 | invitation_body = 'organizations/email/invitation_body.html' 96 | 97 | .. attribute:: InvitationBackend.reminder_subject 98 | 99 | Template path for the reminder email subject. Default:: 100 | 101 | reminder_subject = 'organizations/email/reminder_subject.txt' 102 | 103 | .. attribute:: InvitationBackend.reminder_body 104 | 105 | Template path for the reminder email body. Default:: 106 | 107 | reminder_body = 'organizations/email/reminder_body.html' 108 | 109 | .. attribute:: InvitationBackend.form_class 110 | 111 | Form class which should be used for activating a user account in response to 112 | an invitation. Default:: 113 | 114 | form_class = UserRegistrationForm 115 | 116 | Methods 117 | ------- 118 | 119 | The primary methods of interest are the `invite_by_email` method and the 120 | `get_success_url` method. 121 | 122 | .. method:: InvitationBackend.get_success_url() 123 | 124 | This method behaves as expected and returns a URL to which the user should be 125 | redirected after successfully activating an account. By default it returns the 126 | user to the organization list URL, but can be configured to any URL:: 127 | 128 | def get_success_url(self): 129 | return reverse('my_fave_app') 130 | 131 | .. method:: InvitationBackend.invite_by_email(email, sender=None, request=None, **kwargs) 132 | 133 | This is the primary interface method for the invitation backend. This method 134 | should be referenced from your invitation form or view and if you need to 135 | customize what happens when a user is invited, this is where to do it. 136 | 137 | Usage example in a form class:: 138 | 139 | class AccountUserAddForm(OrganizationUserAddForm): 140 | 141 | class Meta: 142 | model = OrganizationUser 143 | 144 | def save(self, *args, **kwargs): 145 | try: 146 | user = get_user_model().objects.get(email__iexact=self.cleaned_data['email']) 147 | except get_user_model().MultipleObjectsReturned: 148 | raise forms.ValidationError("This email address has been used multiple times.") 149 | except get_user_model().DoesNotExist: 150 | user = invitation_backend().invite_by_email( 151 | self.cleaned_data['email'], 152 | **{'domain': get_current_site(self.request), 153 | 'organization': self.organization}) 154 | 155 | return OrganizationUser.objects.create(user=user, 156 | organization=self.organization) 157 | 158 | .. note:: 159 | As the example shows, the invitation backend does not associate the 160 | individual user with the organization account, it only creates the user so it 161 | can be associated in addition to sending the invitation. 162 | 163 | Use additional keyword arguments passed via `**kwargs` to include 164 | contextual information in the invitation, such as what account the user is 165 | being invited to join. 166 | By default the invitation template requires domain details as per the Django Sites framework 167 | so you can provide either a Site object, or craft the domain kwarg as follows: 168 | 169 | domain={ "name": "My Site", "domain": "www.example.com" } 170 | 171 | .. method:: InvitationBackend.activate_view(request, user_id, token) 172 | 173 | This method is a view for activating a user account via a unique link sent 174 | via email. The view ensures the token matches a user and is valid, that the 175 | user is unregistered, and that the user's entered data is valid (e.g. 176 | password, names). User entered data is validated against the `form_class`. 177 | 178 | The view then ensures the user's `OrganizationUser` connections are 179 | activated, logs the user in with the entered credentials and redirects to the 180 | success URL. 181 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from django.test.client import RequestFactory 4 | from django.test.utils import override_settings 5 | 6 | from organizations.forms import OrganizationAddForm 7 | from organizations.forms import OrganizationForm 8 | from organizations.forms import OrganizationUserAddForm 9 | from organizations.forms import OrganizationUserForm 10 | from organizations.models import Organization 11 | from tests.utils import request_factory_login 12 | 13 | User = get_user_model() 14 | 15 | 16 | class TestOrganizationAddForm(TestCase): 17 | """ 18 | Tests for adding new organizations 19 | """ 20 | 21 | def setUp(self): 22 | self.factory = RequestFactory() 23 | 24 | def test_expected_valid_data_validates(self): 25 | """Test our happy path""" 26 | request = self.factory.request() 27 | form = OrganizationAddForm( 28 | request, 29 | data={"slug": "new_org", "name": "New Org", "email": "cthulu@oldgods.org"}, 30 | ) 31 | self.assertTrue(form.is_valid()) 32 | 33 | def test_add_organization_for_existing_user(self): 34 | user = User.objects.create_user( 35 | "timmy", password="ajsdkfa3", email="timmy@whoa.com" 36 | ) 37 | request = self.factory.request() 38 | form = OrganizationAddForm( 39 | request, data={"slug": "new_org", "name": "New Org", "email": user.email} 40 | ) 41 | self.assertTrue(form.is_valid()) 42 | new_org = form.save() 43 | self.assertTrue(new_org.is_active) 44 | self.assertEqual(new_org.name, "New Org") 45 | 46 | def test_add_organization_for_new_user(self): 47 | user = User.objects.create_user( 48 | "timmy", password="ajsdkfa3", email="timmy@whoa.com" 49 | ) 50 | request = request_factory_login(self.factory, user) 51 | form = OrganizationAddForm( 52 | request, 53 | data={ 54 | "slug": "new_org", 55 | "name": "New Org", 56 | "email": "i.am.new.here@geemail.com", 57 | }, 58 | ) 59 | self.assertTrue(form.is_valid()) 60 | new_org = form.save() 61 | self.assertFalse(new_org.is_active) # Inactive until confirmation 62 | 63 | 64 | class TestOrganizationUserAddForm(TestCase): 65 | fixtures = ["users.json", "orgs.json"] 66 | 67 | def setUp(self): 68 | self.factory = RequestFactory() 69 | self.org = Organization.objects.get(name="Nirvana") 70 | self.owner = self.org.organization_users.get(user__username="kurt") 71 | 72 | def test_multiple_users_exist(self): 73 | User.objects.create_user("asdkjf", password="ajsdkfa", email="bob@bob.com") 74 | User.objects.create_user("asdkjf1", password="ajsdkfa3", email="bob@bob.com") 75 | request = request_factory_login(self.factory, self.owner.user) 76 | form = OrganizationUserAddForm( 77 | request=request, 78 | organization=self.org, 79 | data={"email": "bob@bob.com"}, 80 | ) 81 | self.assertFalse(form.is_valid()) 82 | 83 | def test_add_user_already_in_organization(self): 84 | admin = self.org.organization_users.get(user__username="krist") 85 | request = request_factory_login(self.factory, self.owner.user) 86 | form = OrganizationUserAddForm( 87 | request=request, 88 | organization=self.org, 89 | data={"email": admin.user.email}, 90 | ) 91 | self.assertFalse(form.is_valid()) 92 | 93 | def test_save_org_user_add_form(self): 94 | request = request_factory_login(self.factory, self.owner.user) 95 | form = OrganizationUserAddForm( 96 | request=request, 97 | organization=self.org, 98 | data={"email": "test_email@example.com", "is_admin": False}, 99 | ) 100 | self.assertTrue(form.is_valid()) 101 | form.save() 102 | 103 | 104 | @override_settings(USE_TZ=True) 105 | class TestOrganizationForm(TestCase): 106 | fixtures = ["users.json", "orgs.json"] 107 | 108 | def setUp(self): 109 | self.factory = RequestFactory() 110 | self.org = Organization.objects.get(name="Nirvana") 111 | self.admin = self.org.organization_users.get(user__username="krist") 112 | self.owner = self.org.organization_users.get(user__username="kurt") 113 | 114 | def test_admin_edits_org(self): 115 | user = self.admin.user 116 | request = request_factory_login(self.factory, user) 117 | form = OrganizationForm( 118 | request, 119 | instance=self.org, 120 | data={"name": self.org.name, "slug": self.org.slug, "owner": self.owner.pk}, 121 | ) 122 | self.assertTrue(form.is_valid()) 123 | form = OrganizationForm( 124 | request, 125 | instance=self.org, 126 | data={"name": self.org.name, "slug": self.org.slug, "owner": self.admin.pk}, 127 | ) 128 | self.assertFalse(form.is_valid()) 129 | 130 | def test_owner_edits_org(self): 131 | user = self.owner.user 132 | request = request_factory_login(self.factory, user) 133 | form = OrganizationForm( 134 | request, 135 | instance=self.org, 136 | data={"name": self.org.name, "slug": self.org.slug, "owner": self.owner.pk}, 137 | ) 138 | self.assertTrue(form.is_valid()) 139 | form = OrganizationForm( 140 | request, 141 | instance=self.org, 142 | data={"name": self.org.name, "slug": self.org.slug, "owner": self.admin.pk}, 143 | ) 144 | self.assertTrue(form.is_valid()) 145 | form.save() 146 | self.assertEqual(self.org.owner.organization_user, self.admin) 147 | 148 | 149 | class TestOrganizationUserForm(TestCase): 150 | fixtures = ["users.json", "orgs.json"] 151 | 152 | def setUp(self): 153 | self.factory = RequestFactory() 154 | self.org = Organization.objects.get(name="Nirvana") 155 | self.admin = self.org.organization_users.get(user__username="krist") 156 | self.owner = self.org.organization_users.get(user__username="kurt") 157 | 158 | def test_edit_owner_user(self): 159 | form = OrganizationUserForm(instance=self.owner, data={"is_admin": True}) 160 | self.assertTrue(form.is_valid()) 161 | form = OrganizationUserForm(instance=self.owner, data={"is_admin": False}) 162 | self.assertFalse(form.is_valid()) 163 | 164 | def test_save_org_form(self): 165 | request = request_factory_login(self.factory, self.owner.user) 166 | form = OrganizationForm( 167 | request, 168 | instance=self.org, 169 | data={"name": self.org.name, "slug": self.org.slug, "owner": self.owner.pk}, 170 | ) 171 | self.assertTrue(form.is_valid()) 172 | form.save() 173 | 174 | def test_save_user_form(self): 175 | form = OrganizationUserForm(instance=self.owner, data={"is_admin": True}) 176 | self.assertTrue(form.is_valid()) 177 | form.save() 178 | --------------------------------------------------------------------------------