├── 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 |
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 |
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 |
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 |
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/src/organizations/templates/organizations/organizationuser_remind.html:
--------------------------------------------------------------------------------
1 | {% extends "organizations_base.html" %}
2 | {% block content %}
3 | {{ organization }}
4 |
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 |
3 | {% for organization_user in organization_users %}
4 | -
5 | {{ organization_user }}
6 | {% if not organization_user.user.is_active %}{% trans "Send reminder" %}{% endif %}
7 |
8 | {% endfor %}
9 |
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 |
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 |
5 |
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 |
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 |
7 | - {% trans "Name" %}: {{ organization_user.full_name }}
8 | - {% trans "Email" %}: {{ organization_user.user.email }}
9 |
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 |
--------------------------------------------------------------------------------