Merge branch 'genesis' into 'development'
feat: Genesis See merge request nofusscomputing/projects/django_template!1
This commit is contained in:
8
.cz.yaml
Normal file
8
.cz.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
commitizen:
|
||||
name: cz_conventional_commits
|
||||
prerelease_offset: 1
|
||||
tag_format: $version
|
||||
update_changelog_on_bump: false
|
||||
version: 0.0.1
|
||||
version_scheme: semver
|
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
.git
|
||||
.git*
|
||||
website-template/
|
||||
gitlab-ci/
|
||||
venv/
|
||||
docs/
|
||||
**/*.sqlite3
|
||||
**/static/
|
||||
__pycache__
|
||||
**__pycache__
|
||||
**.pyc
|
||||
** .pytest*
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1 +1,7 @@
|
||||
venv/**
|
||||
*/static/**
|
||||
__pycache__
|
||||
**db.sqlite3
|
||||
**.coverage
|
||||
artifacts/
|
||||
**itam/
|
53
.gitlab-ci.yml
Normal file
53
.gitlab-ci.yml
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
|
||||
variables:
|
||||
MY_PROJECT_ID: "57560288"
|
||||
GIT_SYNC_URL: "https://$GITHUB_USERNAME_ROBOT:$GITHUB_TOKEN_ROBOT@github.com/NoFussComputing/django_template.git"
|
||||
|
||||
# Docker Build / Publish
|
||||
DOCKER_IMAGE_BUILD_TARGET_PLATFORMS: "linux/amd64,linux/arm64"
|
||||
DOCKER_IMAGE_BUILD_NAME: django-template
|
||||
DOCKER_IMAGE_BUILD_REGISTRY: $CI_REGISTRY_IMAGE
|
||||
DOCKER_IMAGE_BUILD_TAG: $CI_COMMIT_SHA
|
||||
|
||||
# Docker Publish
|
||||
DOCKER_IMAGE_PUBLISH_NAME: django-template
|
||||
DOCKER_IMAGE_PUBLISH_REGISTRY: docker.io/nofusscomputing
|
||||
DOCKER_IMAGE_PUBLISH_URL: https://hub.docker.com/r/nofusscomputing/$DOCKER_IMAGE_PUBLISH_NAME
|
||||
|
||||
# Docs NFC
|
||||
PAGES_ENVIRONMENT_PATH: projects/django-template/
|
||||
|
||||
# RELEASE_ADDITIONAL_ACTIONS_BUMP: ./.gitlab/additional_actions_bump.sh
|
||||
|
||||
|
||||
include:
|
||||
- local: .gitlab/pytest.gitlab-ci.yml
|
||||
# - local: .gitlab/unit-test.gitlab-ci.yml
|
||||
- project: nofusscomputing/projects/gitlab-ci
|
||||
ref: development
|
||||
file:
|
||||
- .gitlab-ci_common.yaml
|
||||
- template/automagic.gitlab-ci.yaml
|
||||
|
||||
|
||||
Website.Submodule.Deploy:
|
||||
extends: .submodule_update_trigger
|
||||
variables:
|
||||
SUBMODULE_UPDATE_TRIGGER_PROJECT: nofusscomputing/infrastructure/website
|
||||
environment:
|
||||
url: https://nofusscomputing.com/$PAGES_ENVIRONMENT_PATH
|
||||
name: Documentation
|
||||
rules:
|
||||
- if: # condition_dev_branch_push
|
||||
$CI_COMMIT_BRANCH == "development" &&
|
||||
$CI_PIPELINE_SOURCE == "push"
|
||||
exists:
|
||||
- '{docs/**,pages/**}/*.md'
|
||||
changes:
|
||||
paths:
|
||||
- '{docs/**,pages/**}/*.md'
|
||||
compare_to: 'master'
|
||||
when: always
|
||||
|
||||
- when: never
|
37
.gitlab/pytest.gitlab-ci.yml
Normal file
37
.gitlab/pytest.gitlab-ci.yml
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
|
||||
Unit:
|
||||
stage: test
|
||||
image: python:3.11-alpine3.19
|
||||
needs: []
|
||||
script:
|
||||
- pip install -r requirements.txt
|
||||
- pip install -r requirements_test.txt
|
||||
- cd app
|
||||
- pytest --cov --cov-report term --cov-report xml:../artifacts/coverage.xml --cov-report html:../artifacts/coverage/ --junit-xml=../artifacts/test.junit.xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
artifacts:
|
||||
expire_in: "30 days"
|
||||
when: always
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: artifacts/coverage.xml
|
||||
junit:
|
||||
- artifacts/unit.JUnit.xml
|
||||
paths:
|
||||
- artifacts/
|
||||
rules:
|
||||
|
||||
- if: # Occur on merge
|
||||
$CI_COMMIT_BRANCH
|
||||
&&
|
||||
(
|
||||
$CI_PIPELINE_SOURCE == "push"
|
||||
||
|
||||
$CI_PIPELINE_SOURCE == "web"
|
||||
)
|
||||
when: always
|
||||
|
||||
- when: never
|
||||
|
8
.gitmodules
vendored
Normal file
8
.gitmodules
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
[submodule "gitlab-ci"]
|
||||
path = gitlab-ci
|
||||
url = https://gitlab.com/nofusscomputing/projects/gitlab-ci.git
|
||||
branch = development
|
||||
[submodule "website-template"]
|
||||
path = website-template
|
||||
url = https://gitlab.com/nofusscomputing/infrastructure/website-template.git
|
||||
branch = development
|
10
.nfc_automation.yaml
Normal file
10
.nfc_automation.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
|
||||
role_git_conf:
|
||||
gitlab:
|
||||
submodule_branch: "development"
|
||||
default_branch: development
|
||||
mr_labels: ~"type::automation" ~"impact::0" ~"priority::0"
|
||||
auto_merge: true
|
||||
merge_request:
|
||||
patch_labels: '~"code review::not started"'
|
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"njpwerner.autodocstring",
|
||||
"streetsidesoftware.code-spell-checker-australian-english",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"qwtel.sqlite-viewer",
|
||||
"jebbs.markdown-extended",
|
||||
]
|
||||
}
|
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"gitlab.aiAssistedCodeSuggestions.enabled": false,
|
||||
"gitlab.duoChat.enabled": false,
|
||||
"cSpell.enableFiletypes": [
|
||||
"!python"
|
||||
],
|
||||
}
|
58
CONTRIBUTING.md
Normal file
58
CONTRIBUTING.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Contribution Guide
|
||||
|
||||
|
||||
## Dev Environment
|
||||
|
||||
It's advised to setup a python virtual env for development. this can be done with the following commands.
|
||||
|
||||
``` bash
|
||||
|
||||
python3 -m venv venv
|
||||
|
||||
source venv/bin/activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
```
|
||||
|
||||
To setup the django test server run the following
|
||||
|
||||
``` bash
|
||||
|
||||
cd app
|
||||
|
||||
python manage.py runserver 8002
|
||||
|
||||
python3 manage.py migrate
|
||||
|
||||
python3 manage.py createsuperuser
|
||||
|
||||
# If model changes
|
||||
python3 manage.py makemigrations --noinput
|
||||
|
||||
```
|
||||
|
||||
Updates to python modules will need to be captured with SCM. This can be done by running `pip freeze > requirements.txt` from the running virtual environment.
|
||||
|
||||
|
||||
## Running Tests
|
||||
|
||||
test can be run by running the following:
|
||||
|
||||
1. `pip install -r requirements_test.txt -r requirements.txt`
|
||||
|
||||
1. `pytest --cov --cov-report html --cov=./`
|
||||
|
||||
|
||||
## Docker Container
|
||||
|
||||
``` bash
|
||||
|
||||
cd app
|
||||
|
||||
docker build . --tag django-app:dev
|
||||
|
||||
docker run -d --rm -v ${PWD}/db.sqlite3:/app/db.sqlite3 -p 8002:8000 --name app django-app:dev
|
||||
|
||||
```
|
||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 No Fuss Computing
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
0
app/access/__init__.py
Normal file
0
app/access/__init__.py
Normal file
30
app/access/admin.py
Normal file
30
app/access/admin.py
Normal file
@ -0,0 +1,30 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from .models import *
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
class TeamInline(admin.TabularInline):
|
||||
model = Team
|
||||
extra = 0
|
||||
|
||||
readonly_fields = ['name', 'created', 'modified']
|
||||
fields = ['team_name']
|
||||
|
||||
fk_name = 'organization'
|
||||
|
||||
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
fieldsets = [
|
||||
(None, {"fields": ["name", "slug"]}),
|
||||
#("Date information", {"fields": ["slug"], "classes": ["collapse"]}),
|
||||
]
|
||||
inlines = [TeamInline]
|
||||
list_display = ["name", "created", "modified"]
|
||||
list_filter = ["created"]
|
||||
search_fields = ["team_name"]
|
||||
|
||||
|
||||
admin.site.register(Organization,OrganizationAdmin)
|
||||
|
6
app/access/apps.py
Normal file
6
app/access/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccessConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'access'
|
59
app/access/fields.py
Normal file
59
app/access/fields.py
Normal file
@ -0,0 +1,59 @@
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.template.defaultfilters import slugify
|
||||
|
||||
class AutoCreatedField(models.DateTimeField):
|
||||
"""
|
||||
A DateTimeField that automatically populates itself at
|
||||
object creation.
|
||||
|
||||
By default, sets editable=False, default=datetime.now.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
kwargs.setdefault("editable", False)
|
||||
|
||||
kwargs.setdefault("default", now)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class AutoLastModifiedField(AutoCreatedField):
|
||||
"""
|
||||
A DateTimeField that updates itself on each save() of the model.
|
||||
|
||||
By default, sets editable=False and default=datetime.now.
|
||||
|
||||
"""
|
||||
|
||||
def pre_save(self, model_instance, add):
|
||||
|
||||
value = now()
|
||||
|
||||
setattr(model_instance, self.attname, value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class AutoSlugField(models.SlugField):
|
||||
"""
|
||||
A DateTimeField that updates itself on each save() of the model.
|
||||
|
||||
By default, sets editable=False and default=datetime.now.
|
||||
|
||||
"""
|
||||
|
||||
def pre_save(self, model_instance, add):
|
||||
|
||||
if not model_instance.slug or model_instance.slug == '_':
|
||||
value = model_instance.name.lower().replace(' ', '_')
|
||||
|
||||
setattr(model_instance, self.attname, value)
|
||||
|
||||
return value
|
||||
|
||||
return model_instance.slug
|
||||
|
||||
|
69
app/access/migrations/0001_initial.py
Normal file
69
app/access/migrations/0001_initial.py
Normal file
@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.0.4 on 2024-05-13 16:08
|
||||
|
||||
import access.fields
|
||||
import django.contrib.auth.models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Organization',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', access.fields.AutoSlugField()),
|
||||
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
|
||||
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Organizations',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('group_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='auth.group')),
|
||||
('is_global', models.BooleanField(default=False)),
|
||||
('team_name', models.CharField(default='', max_length=50, verbose_name='Name')),
|
||||
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
|
||||
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Teams',
|
||||
'ordering': ['team_name'],
|
||||
},
|
||||
bases=('auth.group', models.Model),
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.GroupManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamUsers',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
|
||||
('manager', models.BooleanField(blank=True, default=False, verbose_name='manager')),
|
||||
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
|
||||
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team', to='access.team')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Team Users',
|
||||
'ordering': ['user'],
|
||||
},
|
||||
),
|
||||
]
|
0
app/access/migrations/__init__.py
Normal file
0
app/access/migrations/__init__.py
Normal file
147
app/access/mixin.py
Normal file
147
app/access/mixin.py
Normal file
@ -0,0 +1,147 @@
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from .models import Team
|
||||
|
||||
|
||||
class OrganizationMixin():
|
||||
"""Base Organization class"""
|
||||
|
||||
request = None
|
||||
|
||||
user_groups = []
|
||||
|
||||
def object_organization(self) -> int:
|
||||
|
||||
if 'access.models.Organization' in str(type(self.get_object())):
|
||||
|
||||
id = self.get_object().id
|
||||
|
||||
else:
|
||||
|
||||
id = self.get_object().organization.id
|
||||
|
||||
if self.get_object().is_global:
|
||||
|
||||
id = 0
|
||||
|
||||
return id
|
||||
|
||||
|
||||
def is_member(self, organization: int) -> bool:
|
||||
"""Returns true if the current user is a member of the organization
|
||||
|
||||
iterates over the user_organizations list and returns true if the user is a member
|
||||
|
||||
Returns:
|
||||
bool: _description_
|
||||
"""
|
||||
|
||||
is_member = False
|
||||
|
||||
if organization in self.user_organizations():
|
||||
|
||||
return True
|
||||
|
||||
return is_member
|
||||
|
||||
|
||||
def get_permission_required(self):
|
||||
"""
|
||||
Override of 'PermissionRequiredMixin' method so that this mixin can obtain the required permission.
|
||||
"""
|
||||
|
||||
if self.permission_required is None:
|
||||
raise ImproperlyConfigured(
|
||||
f"{self.__class__.__name__} is missing the "
|
||||
f"permission_required attribute. Define "
|
||||
f"{self.__class__.__name__}.permission_required, or override "
|
||||
f"{self.__class__.__name__}.get_permission_required()."
|
||||
)
|
||||
if isinstance(self.permission_required, str):
|
||||
perms = (self.permission_required,)
|
||||
else:
|
||||
perms = self.permission_required
|
||||
return perms
|
||||
|
||||
|
||||
@cached_property
|
||||
def is_manager(self) -> bool:
|
||||
""" Returns true if the current user is a member of the organization"""
|
||||
is_manager = False
|
||||
|
||||
return is_manager
|
||||
|
||||
|
||||
def user_organizations(self) -> list():
|
||||
"""Current Users organizations
|
||||
|
||||
Fetches the Organizations the user is apart of.
|
||||
|
||||
Get All groups the user is part of, fetch the associated team,
|
||||
iterate over the results adding the organization ID to a list to be returned.
|
||||
|
||||
Args:
|
||||
request (_type_): Current http request
|
||||
|
||||
Returns:
|
||||
_type_: _description_
|
||||
"""
|
||||
|
||||
user_organizations = []
|
||||
|
||||
teams = Team.objects
|
||||
|
||||
for group in self.request.user.groups.all():
|
||||
|
||||
team = teams.get(pk=group.id)
|
||||
|
||||
self.user_groups = self.user_groups + [group.id]
|
||||
|
||||
user_organizations = user_organizations + [team.organization.id]
|
||||
|
||||
return user_organizations
|
||||
|
||||
|
||||
# ToDo: Ensure that the group has access to item
|
||||
def has_permission(self) -> bool:
|
||||
|
||||
has_permission = False
|
||||
|
||||
if self.is_member(self.object_organization()) or self.object_organization() == 0:
|
||||
|
||||
groups = Group.objects.filter(pk__in=self.user_groups)
|
||||
|
||||
for group in groups:
|
||||
|
||||
team = Team.objects.filter(pk=group.id)
|
||||
team = team.values('organization_id').get()
|
||||
|
||||
for permission in group.permissions.values('content_type__app_label', 'codename').all():
|
||||
|
||||
assembled_permission = str(permission["content_type__app_label"]) + '.' + str(permission["codename"])
|
||||
|
||||
if assembled_permission in self.get_permission_required()[0] and (team['organization_id'] == self.object_organization() or self.object_organization() == 0):
|
||||
|
||||
return True
|
||||
|
||||
return has_permission
|
||||
|
||||
|
||||
|
||||
class OrganizationPermission(OrganizationMixin):
|
||||
"""checking organization membership"""
|
||||
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
|
||||
if hasattr(self, 'get_object'):
|
||||
|
||||
if not self.has_permission() and not request.user.is_superuser:
|
||||
raise PermissionDenied('You are not part of this organization')
|
||||
|
||||
return super().dispatch(self.request, *args, **kwargs)
|
120
app/access/models.py
Normal file
120
app/access/models.py
Normal file
@ -0,0 +1,120 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
||||
from .fields import *
|
||||
|
||||
|
||||
class Organization(models.Model):
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Organizations"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
if self.slug == '_':
|
||||
self.slug = self.name.lower().replace(' ', '_')
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
id = models.AutoField(
|
||||
primary_key=True,
|
||||
unique=True,
|
||||
blank=False
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
blank = False,
|
||||
max_length = 50,
|
||||
unique = True,
|
||||
)
|
||||
|
||||
slug = AutoSlugField()
|
||||
|
||||
created = AutoCreatedField()
|
||||
|
||||
modified = AutoLastModifiedField()
|
||||
|
||||
|
||||
class TenancyObject(models.Model):
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
is_global = models.BooleanField(
|
||||
default = False,
|
||||
blank = False
|
||||
)
|
||||
|
||||
|
||||
class Team(Group, TenancyObject):
|
||||
class Meta:
|
||||
# proxy = True
|
||||
verbose_name_plural = "Teams"
|
||||
ordering = ['team_name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
||||
self.name = self.organization.name.lower().replace(' ', '_') + '_' + self.team_name.lower().replace(' ', '_')
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
team_name = models.CharField(
|
||||
verbose_name = 'Name',
|
||||
blank = False,
|
||||
max_length = 50,
|
||||
unique = False,
|
||||
default = ''
|
||||
)
|
||||
|
||||
created = AutoCreatedField()
|
||||
|
||||
modified = AutoLastModifiedField()
|
||||
|
||||
|
||||
class TeamUsers(models.Model):
|
||||
|
||||
class Meta:
|
||||
# proxy = True
|
||||
verbose_name_plural = "Team Users"
|
||||
ordering = ['user']
|
||||
|
||||
id = models.AutoField(
|
||||
primary_key=True,
|
||||
unique=True,
|
||||
blank=False
|
||||
)
|
||||
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
related_name="team",
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
manager = models.BooleanField(
|
||||
verbose_name='manager',
|
||||
default=False,
|
||||
blank=True
|
||||
)
|
||||
|
||||
created = AutoCreatedField()
|
||||
|
||||
modified = AutoLastModifiedField()
|
23
app/access/templates/access/index.html.j2
Normal file
23
app/access/templates/access/index.html.j2
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Organizations{% endblock %}
|
||||
{% block content_header_icon %}{% endblock %}
|
||||
|
||||
{% block body%}
|
||||
|
||||
<table class="data">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
{% for org in organization_list %}
|
||||
<tr>
|
||||
<td><a href="/organization/{{ org.id }}/">{{ org.name }}</a></td>
|
||||
<td>{{ org.created }}</td>
|
||||
<td>{{ org.modified }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</table>
|
||||
{% endblock %}
|
35
app/access/templates/access/organization.html.j2
Normal file
35
app/access/templates/access/organization.html.j2
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Organization - {{ organization.name }}{% endblock %}
|
||||
|
||||
{% block body%}
|
||||
|
||||
|
||||
<section class="content-header">
|
||||
<fieldset><label>Name</label><!-- <input type="text" value="{{ organization.name }}" /> -->{{form.name}}</fieldset>
|
||||
<fieldset><label>Created</label><input type="text" value="{{ organization.created }}" readonly /></fieldset>
|
||||
<fieldset><label>Modified</label><input type="text" value="{{ organization.modified }}" readonly /></fieldset>
|
||||
</section>
|
||||
|
||||
<input type="button" value="<< Back" onclick="window.location='{% url 'Access:Organizations' %}';">
|
||||
<input type="button" value="New Team" onclick="window.location='{% url 'Access:_team_add' organization.id %}';">
|
||||
|
||||
<hr />
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team Name</th>
|
||||
<th>Created</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for field in teams %}
|
||||
<tr>
|
||||
<td><a href="{% url 'Access:_team' organization_id=organization.id pk=field.id %}">{{ field.team_name }}</a></td>
|
||||
<td>{{ field.created }}</td>
|
||||
<td>{{ field.modified }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
62
app/access/templates/access/team.html.j2
Normal file
62
app/access/templates/access/team.html.j2
Normal file
@ -0,0 +1,62 @@
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Team - {{ team.team_name }}{% endblock %}
|
||||
|
||||
{% block body%}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<input name="organization" id="id_organization" type="hidden" value="{{ organization.id }}">
|
||||
<section class="content-header">
|
||||
<fieldset><label>Name</label><input name="name" required id="id_name" type="text" value="{{ team.team_name }}" /></fieldset>
|
||||
<fieldset><label>Created</label><input name="created" type="text" value="{{ team.created }}" readonly /></fieldset>
|
||||
<fieldset><label>Modified</label><input name="modified" type="text" value="{{ team.modified }}" readonly /></fieldset>
|
||||
<fieldset><label>Permissions</label>
|
||||
<select name="permissions" id="id_permissions" style="height: 200px;" multiple>
|
||||
{% for permission in permissions %}
|
||||
{% if 'administration' not in permission.content_type|lower and 'authorization' not in permission.content_type|lower and 'content types' not in permission.content_type|lower and 'session' not in permission.content_type|lower and 'python social auth' not in permission.content_type|lower and 'add_organization' not in permission.codename|lower and 'delete_organization' not in permission.codename|lower %}
|
||||
<option value="{{ permission.id }}" {% for team_permission in team.permissions.all %}{% if permission.id == team_permission.id %}selected{% endif %}{% endfor %}>{{ permission.content_type }} | {{ permission.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
<input style="display:unset;" type="submit" value="Submit">
|
||||
</form>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
<input type="button" value="<< Back" onclick="window.location='{% url 'Access:_organization' pk=organization.id %}';">
|
||||
<input type="button" value="Delete Team"
|
||||
onclick="window.location='{% url 'Access:_team_delete' organization_id=organization.id pk=team.id %}';">
|
||||
<input type="button" value="New User"
|
||||
onclick="window.location='{% url 'Access:_team_user_add' organization_id=organization.id pk=team.id %}';">
|
||||
{{ formset.management_form }}
|
||||
|
||||
<table id="formset" class="form">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Manager</th>
|
||||
<th>Created</th>
|
||||
<th>Modified</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{% for field in teamusers %}
|
||||
<tr>
|
||||
<td>{{ field.user }}</td>
|
||||
<td><input type="checkbox" {% if field.manager %}checked{% endif %} disabled></td>
|
||||
<td>{{ field.created }}</td>
|
||||
<td>{{ field.modified }}</td>
|
||||
<td><a
|
||||
href="{% url 'Access:_team_user_delete' organization_id=organization.id team_id=field.team_id pk=field.id %}">Delete</a></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
122
app/access/tests/test_auth_app_structure.py
Normal file
122
app/access/tests/test_auth_app_structure.py
Normal file
@ -0,0 +1,122 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import reverse
|
||||
from django.test import TestCase, Client
|
||||
|
||||
import pytest
|
||||
import unittest
|
||||
import requests
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from access.models import Organization
|
||||
|
||||
# class Test_app_structure_auth(unittest.TestCase):
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user() -> User:
|
||||
return User.objects.create_user(username="testuser", password="testpassword")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def organization() -> Organization:
|
||||
return Organization.objects.create(
|
||||
name='Test org',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_require_login_organizations():
|
||||
"""Some docstring defining what the test is checking."""
|
||||
client = Client()
|
||||
url = reverse('Access:Organizations')
|
||||
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
@pytest.mark.skip(reason="to be re-written for orgmixin")
|
||||
@pytest.mark.django_db
|
||||
def test_require_login_organization_pk(organization):
|
||||
"""Ensure login is required to view an organization"""
|
||||
client = Client()
|
||||
url = reverse('Access:_organization', kwargs={'organization_id': 1})
|
||||
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_login_view_organizations_no_permission(user):
|
||||
"""Some docstring defining what the test is checking."""
|
||||
client = Client()
|
||||
url = reverse('Access:Organizations')
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_organizations_permission_change(user):
|
||||
"""ensure user with permission can change organization
|
||||
|
||||
Args:
|
||||
user (_type_): _description_
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_organizations_permission_delete_denied(user):
|
||||
"""ensure non-admin user cant delete organization
|
||||
|
||||
Args:
|
||||
user (_type_): _description_
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_team_permission_add_in_org(user):
|
||||
"""ensure user with add permission to an organization can add team
|
||||
|
||||
Args:
|
||||
user (_type_): _description_
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_team_permission_add_not_in_org(user):
|
||||
"""ensure user with add permission to an organization can add team
|
||||
|
||||
Args:
|
||||
user (_type_): _description_
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_team_permission_change(user):
|
||||
"""ensure user can change a team
|
||||
|
||||
Args:
|
||||
user (_type_): _description_
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_team_permission_delete(user):
|
||||
"""ensure user can delete a team
|
||||
|
||||
Args:
|
||||
user (_type_): _description_
|
||||
"""
|
||||
pass
|
||||
|
||||
|
205
app/access/tests/test_model_app_structure.py
Normal file
205
app/access/tests/test_model_app_structure.py
Normal file
@ -0,0 +1,205 @@
|
||||
from django.test import TestCase
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import unittest
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from access.models import Organization, Team
|
||||
|
||||
|
||||
# @pytest.fixture
|
||||
# def organization() -> Organization:
|
||||
# return Organization.objects.create(
|
||||
# name='Test org',
|
||||
# )
|
||||
|
||||
|
||||
# @pytest.fixture
|
||||
# def team() -> Team:
|
||||
# return Team.objects.create(
|
||||
# name='Team one',
|
||||
# organization = Organization.objects.create(
|
||||
# name='Test org',
|
||||
# ),
|
||||
# )
|
||||
|
||||
|
||||
######################################################################
|
||||
# SoF for loop for tests
|
||||
# for test in ['organization','team', 'users']
|
||||
#
|
||||
# permissions for each item as per the action plus view of the parent item
|
||||
######################################################################
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_organization_view(user):
|
||||
"""User of organization can view
|
||||
|
||||
user requires permissions organization view
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_organization_no_view(user):
|
||||
"""User not part of organization cant view
|
||||
|
||||
user requires permissions organization view
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_view(user):
|
||||
""" Ensure team can be viewed when user has correct permissions
|
||||
|
||||
user requires permissions organization view and team view
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_no_view(user):
|
||||
""" Ensure team can't be viewed when user is missing permissions
|
||||
|
||||
user requires permissions organization view and team view
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_add(user):
|
||||
"""Ensure team can be added when user has correct permissions
|
||||
|
||||
user requires permissions organization view and team add
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_no_view(user):
|
||||
"""Ensure team can't be added when user is missing permissions
|
||||
|
||||
user requires permissions organization view and team add
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_change(user):
|
||||
"""Ensure team can be changed when user has correct permissions
|
||||
|
||||
user requires permissions organization view and team change
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_no_change(user):
|
||||
"""Ensure team can't be change when user is missing permissions
|
||||
|
||||
user requires permissions organization view and team change
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_delete(user):
|
||||
"""Ensure team can be deleted when user has correct permissions
|
||||
|
||||
user requires permissions organization view and team delete
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_team_permission_no_delete(user):
|
||||
"""Ensure team can't be deleted when user is missing permissions
|
||||
|
||||
user requires permissions organization view and team delete
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_add(user):
|
||||
"""Ensure user can be added when user has correct permissions
|
||||
|
||||
user requires permissions team view and user add
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_no_add(user):
|
||||
"""Ensure user can't be added when user is missing permissions
|
||||
|
||||
user requires permissions team view and user add
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_add_team_manager(user):
|
||||
"""Ensure user can be added when user is team manager
|
||||
|
||||
user requires permissions team view and user add
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_change(user):
|
||||
"""Ensure user can be changed when user has correct permissions
|
||||
|
||||
user requires permissions team view and user change
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_no_change(user):
|
||||
"""Ensure user can't be change when user is missing permissions
|
||||
|
||||
user requires permissions team view and user change
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_delete(user):
|
||||
"""Ensure user can be deleted when user has correct permissions
|
||||
|
||||
user requires permissions team view and user delete
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_no_delete(user):
|
||||
"""Ensure user can't be deleted when user is missing permissions
|
||||
|
||||
user requires permissions team view and user delete
|
||||
"""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="to be written")
|
||||
def test_authorization_user_permission_delete_team_manager(user):
|
||||
"""Ensure user can be deleted when user is team manager
|
||||
|
||||
user requires permissions team view and user delete
|
||||
"""
|
||||
pass
|
||||
|
||||
######################################################################
|
||||
# EoF for loop for tests
|
||||
# for test in ['organization','team']
|
||||
######################################################################
|
||||
|
||||
# is_superuser to be able to view, add, change, delete for all objects
|
||||
|
17
app/access/urls.py
Normal file
17
app/access/urls.py
Normal file
@ -0,0 +1,17 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from .views import team, organization, user
|
||||
|
||||
app_name = "Access"
|
||||
urlpatterns = [
|
||||
path("", organization.IndexView.as_view(), name="Organizations"),
|
||||
path("<int:pk>/", organization.View.as_view(), name="_organization"),
|
||||
path("<int:pk>/edit", organization.Change.as_view(), name="_organization_change"),
|
||||
path("<int:organization_id>/team/<int:pk>/", team.View.as_view(), name="_team"),
|
||||
path("<int:pk>/team/add", team.Add.as_view(), name="_team_add"),
|
||||
path("<int:organization_id>/team/<int:pk>/edit", team.Change.as_view(), name="_team_change"),
|
||||
path("<int:organization_id>/team/<int:pk>/delete", team.Delete.as_view(), name="_team_delete"),
|
||||
path("<int:organization_id>/team/<int:pk>/user/add", user.Add.as_view(), name="_team_user_add"),
|
||||
path("<int:organization_id>/team/<int:team_id>/user/<int:pk>/delete", user.Delete.as_view(), name="_team_user_delete"),
|
||||
]
|
68
app/access/views/organization.py
Normal file
68
app/access/views/organization.py
Normal file
@ -0,0 +1,68 @@
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
|
||||
from django.views import generic
|
||||
|
||||
from access.mixin import *
|
||||
from access.models import *
|
||||
|
||||
|
||||
|
||||
class IndexView(PermissionRequiredMixin, OrganizationPermission, generic.ListView):
|
||||
permission_required = 'access.view_organization'
|
||||
template_name = 'access/index.html.j2'
|
||||
context_object_name = "organization_list"
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
if self.request.user.is_superuser:
|
||||
|
||||
return Organization.objects.filter()
|
||||
|
||||
else:
|
||||
|
||||
return Organization.objects.filter(pk__in=self.user_organizations())
|
||||
|
||||
|
||||
|
||||
class View(LoginRequiredMixin, OrganizationPermission, generic.UpdateView):
|
||||
model = Organization
|
||||
permission_required = 'access.view_organization'
|
||||
template_name = "access/organization.html.j2"
|
||||
fields = ["name", 'id']
|
||||
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['pk']}/"
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
return Organization.objects.filter(pk=self.kwargs['pk'])
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['organization'] = Organization.objects.get(pk=self.kwargs['pk'])
|
||||
|
||||
context['teams'] = Team.objects.filter(organization=self.kwargs['pk'])
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
class Change(LoginRequiredMixin, OrganizationPermission, generic.DetailView):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Delete(LoginRequiredMixin, OrganizationPermission, generic.DetailView):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
134
app/access/views/team.py
Normal file
134
app/access/views/team.py
Normal file
@ -0,0 +1,134 @@
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.views import generic
|
||||
|
||||
from access.models import Team, TeamUsers, Organization
|
||||
from access.mixin import *
|
||||
|
||||
|
||||
|
||||
class View(OrganizationPermission, generic.UpdateView):
|
||||
model = Team
|
||||
permission_required = [
|
||||
'access.add_team',
|
||||
'access.change_team',
|
||||
]
|
||||
template_name = 'access/team.html.j2'
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
'id',
|
||||
'organization',
|
||||
'permissions'
|
||||
]
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
organization = Organization.objects.get(pk=self.kwargs['organization_id'])
|
||||
|
||||
context['organization'] = organization
|
||||
|
||||
team = Team.objects.get(pk=self.kwargs['pk'])
|
||||
|
||||
teamusers = TeamUsers.objects.filter(team=self.kwargs['pk'])
|
||||
|
||||
context['teamusers'] = teamusers
|
||||
context['permissions'] = Permission.objects.filter()
|
||||
|
||||
return context
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['organization_id']}/team/{self.kwargs['pk']}/"
|
||||
|
||||
|
||||
|
||||
class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView):
|
||||
model = Team
|
||||
permission_required = [
|
||||
'access.add_team',
|
||||
]
|
||||
template_name = 'form.html.j2'
|
||||
fields = [
|
||||
'team_name',
|
||||
]
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.organization = Organization.objects.get(pk=self.kwargs['pk'])
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['pk']}/"
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['content_title'] = 'Add Team'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class Change(PermissionRequiredMixin, OrganizationPermission, generic.UpdateView):
|
||||
model = Team
|
||||
permission_required = [
|
||||
'access.change_team',
|
||||
]
|
||||
template_name = 'form.html.j2'
|
||||
fields = [
|
||||
'team_name',
|
||||
'permissions',
|
||||
'organization'
|
||||
]
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['pk']}/"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['content_title'] = 'Edit Team'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
class Delete(PermissionRequiredMixin, OrganizationPermission, generic.DeleteView):
|
||||
model = Team
|
||||
permission_required = [
|
||||
'access.delete_team'
|
||||
]
|
||||
template_name = 'form.html.j2'
|
||||
fields = [
|
||||
'team_name',
|
||||
'permissions',
|
||||
'organization'
|
||||
]
|
||||
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['organization_id']}/"
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['content_title'] = 'Delete Team'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
78
app/access/views/user.py
Normal file
78
app/access/views/user.py
Normal file
@ -0,0 +1,78 @@
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.views import generic
|
||||
|
||||
from access.mixin import OrganizationPermission
|
||||
from access.models import Team, TeamUsers
|
||||
|
||||
|
||||
|
||||
class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView):
|
||||
model = TeamUsers
|
||||
permission_required = [
|
||||
'access.view_team',
|
||||
'access.add_teamusers'
|
||||
]
|
||||
template_name = 'form.html.j2'
|
||||
fields = [
|
||||
'user',
|
||||
'manager'
|
||||
]
|
||||
|
||||
|
||||
def form_valid(self, form):
|
||||
team = Team.objects.get(pk=self.kwargs['pk'])
|
||||
form.instance.team = team
|
||||
|
||||
group = Group.objects.get(pk=team.group_ptr_id)
|
||||
user = User.objects.get(pk=self.request.POST['user'][0])
|
||||
user.groups.add(group)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['organization_id']}/team/{self.kwargs['pk']}"
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['content_title'] = 'Add Team User'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class Delete(PermissionRequiredMixin, OrganizationPermission, generic.DeleteView):
|
||||
model = TeamUsers
|
||||
permission_required = [
|
||||
'access.view_team',
|
||||
'access.delete_teamusers'
|
||||
]
|
||||
template_name = 'form.html.j2'
|
||||
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
team = Team.objects.get(pk=self.kwargs['team_id'])
|
||||
teamuser = TeamUsers.objects.get(pk=self.kwargs['pk'])
|
||||
|
||||
group = Group.objects.get(pk=team.group_ptr_id)
|
||||
|
||||
user = User.objects.get(pk=teamuser.user_id)
|
||||
|
||||
user.groups.remove(group)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return f"/organization/{self.kwargs['organization_id']}/team/{self.kwargs['team_id']}"
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context['content_title'] = 'Delete Team User'
|
||||
|
||||
return context
|
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
6
app/api/apps.py
Normal file
6
app/api/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'api'
|
30
app/api/serializers/access.py
Normal file
30
app/api/serializers/access.py
Normal file
@ -0,0 +1,30 @@
|
||||
from rest_framework import serializers
|
||||
from access.models import Organization, Team
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
"group_ptr_id",
|
||||
"name",
|
||||
)
|
||||
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name="_api_organization", format="html"
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
'url',
|
||||
)
|
16
app/api/urls.py
Normal file
16
app/api/urls.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.urls import path
|
||||
from rest_framework.urlpatterns import format_suffix_patterns
|
||||
|
||||
from .views import access, index
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", index.IndexView.as_view(), name='_api_home'),
|
||||
path("organization/", access.OrganizationList.as_view(), name='_api_orgs'),
|
||||
path("organization/<int:pk>/", access.OrganizationDetail.as_view(), name='_api_organization'),
|
||||
path("organization/<int:organization_id>/team/<int:group_ptr_id>/", access.TeamDetail.as_view(), name='_api_team'),
|
||||
path("organization/team/", access.TeamList.as_view(), name='_api_teams'),
|
||||
|
||||
]
|
||||
|
||||
urlpatterns = format_suffix_patterns(urlpatterns)
|
42
app/api/views/access.py
Normal file
42
app/api/views/access.py
Normal file
@ -0,0 +1,42 @@
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
|
||||
|
||||
from rest_framework import generics
|
||||
|
||||
from access.models import Organization, Team
|
||||
from api.serializers.access import OrganizationSerializer, TeamSerializer
|
||||
|
||||
|
||||
|
||||
class OrganizationList(PermissionRequiredMixin, LoginRequiredMixin, generics.ListCreateAPIView):
|
||||
permission_required = 'access.view_organization'
|
||||
queryset = Organization.objects.all()
|
||||
serializer_class = OrganizationSerializer
|
||||
|
||||
|
||||
def get_view_name(self):
|
||||
return "Organizations"
|
||||
|
||||
|
||||
|
||||
class OrganizationDetail(PermissionRequiredMixin, LoginRequiredMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||
permission_required = 'access.view_organization'
|
||||
queryset = Organization.objects.all()
|
||||
serializer_class = OrganizationSerializer
|
||||
|
||||
|
||||
def get_view_name(self):
|
||||
return "Organization"
|
||||
|
||||
|
||||
|
||||
class TeamList(generics.ListCreateAPIView):
|
||||
queryset = Team.objects.all()
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
|
||||
|
||||
class TeamDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = Team.objects.all()
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
lookup_field = 'group_ptr_id'
|
33
app/api/views/index.py
Normal file
33
app/api/views/index.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from rest_framework import generics, permissions, routers
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
|
||||
|
||||
class IndexView(PermissionRequiredMixin, LoginRequiredMixin, routers.APIRootView):
|
||||
|
||||
permission_required = 'access.view_organization'
|
||||
|
||||
def get_view_name(self):
|
||||
return "My API"
|
||||
|
||||
def get_view_description(self, html=False) -> str:
|
||||
text = "My REST API"
|
||||
if html:
|
||||
return mark_safe(f"<p>{text}</p>")
|
||||
else:
|
||||
return text
|
||||
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return Response(
|
||||
{
|
||||
"organizations": reverse("_api_orgs", request=request),
|
||||
"teams": reverse("_api_teams", request=request),
|
||||
}
|
||||
)
|
0
app/app/__init__.py
Normal file
0
app/app/__init__.py
Normal file
16
app/app/asgi.py
Normal file
16
app/app/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for itsm project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
|
||||
|
||||
application = get_asgi_application()
|
100
app/app/context_processors.py
Normal file
100
app/app/context_processors.py
Normal file
@ -0,0 +1,100 @@
|
||||
import re
|
||||
|
||||
from .urls import urlpatterns
|
||||
|
||||
from django.urls import URLPattern, URLResolver
|
||||
|
||||
|
||||
def request(request):
|
||||
return request.get_full_path()
|
||||
|
||||
def nav_items(context) -> list(dict()):
|
||||
""" Fetch All Project URLs
|
||||
|
||||
Collect the project URLs for use in creating the site navigation.
|
||||
|
||||
The returned list contains a dictionary with the following items:
|
||||
name: {str} Group Name
|
||||
urls: {list} List of URLs for the group
|
||||
is_active: {bool} if any of the links in this group are active
|
||||
|
||||
Each group url list item contains a dicionary with the following items:
|
||||
name: {str} The display name for the link
|
||||
url: {str} link URL
|
||||
is_active: {bool} if this link is the active URL
|
||||
|
||||
Returns:
|
||||
_type_: _description_
|
||||
"""
|
||||
|
||||
dnav = []
|
||||
re_pattern = re.compile('[a-z/0-9]+')
|
||||
|
||||
for nav_group in urlpatterns:
|
||||
|
||||
group_active = False
|
||||
|
||||
ignored_apps = [
|
||||
'admin',
|
||||
'djdt', # Debug application
|
||||
]
|
||||
|
||||
nav_items = []
|
||||
|
||||
if (
|
||||
isinstance(nav_group, URLPattern)
|
||||
):
|
||||
|
||||
group_name = str(nav_group.name)
|
||||
|
||||
elif (
|
||||
isinstance(nav_group, URLResolver)
|
||||
):
|
||||
|
||||
if nav_group.app_name is not None and nav_group.app_name not in ignored_apps:
|
||||
|
||||
group_name = str(nav_group.app_name)
|
||||
|
||||
for pattern in nav_group.url_patterns:
|
||||
|
||||
is_active = False
|
||||
|
||||
url = '/' + str(nav_group.pattern) + str(pattern.pattern)
|
||||
|
||||
if str(context.path) == url:
|
||||
|
||||
is_active = True
|
||||
|
||||
if str(context.path).startswith('/' + str(nav_group.pattern)):
|
||||
group_active = True
|
||||
|
||||
if (
|
||||
pattern.pattern.name is not None
|
||||
and
|
||||
not str(pattern.pattern.name).startswith('_')
|
||||
):
|
||||
|
||||
name = str(pattern.name)
|
||||
|
||||
nav_items = nav_items + [ {
|
||||
'name': name,
|
||||
'url': url,
|
||||
'is_active': is_active
|
||||
} ]
|
||||
|
||||
if len(nav_items) > 0:
|
||||
|
||||
dnav = dnav + [{
|
||||
'name': group_name,
|
||||
'urls': nav_items,
|
||||
'is_active': group_active
|
||||
}]
|
||||
|
||||
|
||||
return dnav
|
||||
|
||||
|
||||
def navigation(context):
|
||||
return {
|
||||
'nav_items': nav_items(context)
|
||||
}
|
198
app/app/settings.py
Normal file
198
app/app/settings.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""
|
||||
Django settings for itsm project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.0.4.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||
"""
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-b*41-$afq0yl)1e#qpz^-nbt-opvjwb#avv++b9rfdxa@b55sk'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = [ '*' ]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'rest_framework_json_api',
|
||||
'social_django',
|
||||
'core.apps.CoreConfig',
|
||||
'access.apps.AccessConfig',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += [
|
||||
'debug_toolbar',
|
||||
]
|
||||
|
||||
MIDDLEWARE += [
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
]
|
||||
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'app.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / "templates"],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'social_django.context_processors.backends',
|
||||
'social_django.context_processors.login_redirect',
|
||||
'app.context_processors.navigation',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'app.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
# },
|
||||
]
|
||||
|
||||
LOGIN_REDIRECT_URL = "home"
|
||||
LOGOUT_REDIRECT_URL = "login"
|
||||
|
||||
LOGIN_URL = '/account/login'
|
||||
LOGIN_REQUIRED = True
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "project-static",
|
||||
]
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
SITE_TITLE = "Site Title"
|
||||
|
||||
|
||||
API_ENABLED = False # Disabled until setup and secure
|
||||
|
||||
if API_ENABLED:
|
||||
|
||||
INSTALLED_APPS += [
|
||||
'api.apps.ApiConfig',
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'PAGE_SIZE': 10,
|
||||
'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS':
|
||||
'rest_framework_json_api.pagination.JsonApiPageNumberPagination',
|
||||
'DEFAULT_PARSER_CLASSES': (
|
||||
'rest_framework_json_api.parsers.JSONParser',
|
||||
'rest_framework.parsers.FormParser',
|
||||
'rest_framework.parsers.MultiPartParser'
|
||||
),
|
||||
'DEFAULT_RENDERER_CLASSES': (
|
||||
'rest_framework_json_api.renderers.JSONRenderer',
|
||||
'rest_framework_json_api.renderers.BrowsableAPIRenderer',
|
||||
),
|
||||
'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'rest_framework_json_api.filters.QueryParameterValidationFilter',
|
||||
'rest_framework_json_api.filters.OrderingFilter',
|
||||
'rest_framework_json_api.django_filters.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
),
|
||||
'SEARCH_PARAM': 'filter[search]',
|
||||
'TEST_REQUEST_RENDERER_CLASSES': (
|
||||
'rest_framework_json_api.renderers.JSONRenderer',
|
||||
),
|
||||
'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json'
|
||||
}
|
41
app/app/tests/test_01_settings.py
Normal file
41
app/app/tests/test_01_settings.py
Normal file
@ -0,0 +1,41 @@
|
||||
from app import settings
|
||||
|
||||
import pytest
|
||||
import unittest
|
||||
|
||||
class Test_aa_settings_default(unittest.TestCase):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_setting_api_disabled_default(self):
|
||||
""" As the API is only partially developed, it must be disabled.
|
||||
|
||||
This test can be removed when the API has been fully developed and functioning as it should.
|
||||
"""
|
||||
|
||||
assert not settings.API_ENABLED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_setting_login_required_default(self):
|
||||
""" By default login should be required
|
||||
"""
|
||||
|
||||
assert settings.LOGIN_REQUIRED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_setting_use_tz_default(self):
|
||||
""" Ensure that 'USE_TZ = True' is within settings
|
||||
"""
|
||||
|
||||
assert settings.USE_TZ
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_setting_debug_off(self):
|
||||
""" Ensure that debug is off within settings by default
|
||||
|
||||
Debug is only required during development with this setting must always remain off within the committed code.
|
||||
"""
|
||||
|
||||
assert not settings.DEBUG
|
38
app/app/tests/test_auth.py
Normal file
38
app/app/tests/test_auth.py
Normal file
@ -0,0 +1,38 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import reverse
|
||||
from django.test import TestCase, Client
|
||||
|
||||
import pytest
|
||||
import unittest
|
||||
|
||||
from access.models import Organization
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_setting_login_required():
|
||||
"""Some docstring defining what the test is checking."""
|
||||
client = Client()
|
||||
url = reverse('home')
|
||||
# client.force_login(user)
|
||||
# default_settings = settings
|
||||
settings.LOGIN_REQUIRED = True
|
||||
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# settings = default_settings
|
||||
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_setting_login_required_not():
|
||||
"""Some docstring defining what the test is checking."""
|
||||
client = Client()
|
||||
url = reverse('home')
|
||||
|
||||
settings.LOGIN_REQUIRED = False
|
||||
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
45
app/app/urls.py
Normal file
45
app/app/urls.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""
|
||||
URL configuration for itsm project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import include, path
|
||||
|
||||
from .views import HomeView
|
||||
|
||||
urlpatterns = [
|
||||
path('', HomeView.as_view(), name='home'),
|
||||
path('admin/', admin.site.urls, name='_administration'),
|
||||
path('account/password_change/', auth_views.PasswordChangeView.as_view(template_name="password_change.html.j2"),
|
||||
name="change_password"),
|
||||
path("account/", include("django.contrib.auth.urls")),
|
||||
path("organization/", include("access.urls")),
|
||||
|
||||
]
|
||||
|
||||
if settings.API_ENABLED:
|
||||
urlpatterns += [
|
||||
|
||||
path("api/", include("api.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
urlpatterns += [
|
||||
|
||||
path("__debug__/", include("debug_toolbar.urls"), name='_debug'),
|
||||
]
|
1
app/app/views/__init__.py
Normal file
1
app/app/views/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .home import *
|
18
app/app/views/home.py
Normal file
18
app/app/views/home.py
Normal file
@ -0,0 +1,18 @@
|
||||
import requests
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.generic import View
|
||||
|
||||
|
||||
class HomeView(View):
|
||||
template_name = 'home.html.j2'
|
||||
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated and settings.LOGIN_REQUIRED:
|
||||
return redirect(f"{settings.LOGIN_URL}?next={request.path}")
|
||||
|
||||
context = {}
|
||||
|
||||
return render(request, self.template_name, context)
|
16
app/app/wsgi.py
Normal file
16
app/app/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for itsm project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
|
||||
|
||||
application = get_wsgi_application()
|
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
6
app/core/apps.py
Normal file
6
app/core/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
0
app/core/templatetags/__init__.py
Normal file
0
app/core/templatetags/__init__.py
Normal file
9
app/core/templatetags/settings_value.py
Normal file
9
app/core/templatetags/settings_value.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag
|
||||
def settings_value(name):
|
||||
|
||||
return getattr(settings, name, "")
|
22
app/manage.py
Executable file
22
app/manage.py
Executable file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
271
app/project-static/base.css
Normal file
271
app/project-static/base.css
Normal file
@ -0,0 +1,271 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
width: 275px;
|
||||
height: 76px;
|
||||
line-height: 76px;
|
||||
vertical-align: middle;
|
||||
padding: 0%;
|
||||
margin: 0%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
margin: 0px;
|
||||
vertical-align: middle;
|
||||
align-content: center;
|
||||
padding-right: 50px;
|
||||
padding-left: 50px
|
||||
}
|
||||
|
||||
span#content_header_icon {
|
||||
float: right;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
margin-right: 10px;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
color: #177ee6;
|
||||
}
|
||||
|
||||
/* .icon {
|
||||
display: block;
|
||||
content: none;
|
||||
background-color: #3e8e41;
|
||||
} */
|
||||
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
background-color: #151515;
|
||||
text-align: center;
|
||||
color: white;
|
||||
height: 76px;
|
||||
/* line-height: 76px; */
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
section {
|
||||
display: flexbox;
|
||||
width: 100%;
|
||||
padding-top: 76px;
|
||||
padding-left: 275px;
|
||||
}
|
||||
|
||||
article {
|
||||
background-color: #fff;
|
||||
padding: 10px;
|
||||
margin: 20px;
|
||||
border: 1px solid #e6dcdc;
|
||||
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
line-height: 76px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 0%;
|
||||
margin: 0%;
|
||||
}
|
||||
|
||||
/* Style The Dropdown Button */
|
||||
.dropbtn {
|
||||
background-color: #177ee6;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.accbtn {
|
||||
background-color: inherit;
|
||||
color: #000;
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
header .dropdown {
|
||||
padding-top: 19px;
|
||||
}
|
||||
|
||||
/* The container <div> - needed to position the dropdown content */
|
||||
.dropdown {
|
||||
/* position: fixed; */
|
||||
/* display: inline-block; */
|
||||
display: inline-flexbox;
|
||||
/* left: 300px; */
|
||||
/* position: relative; */
|
||||
/* vertical-align: middle; */
|
||||
|
||||
}
|
||||
|
||||
/* Dropdown Content (Hidden by Default) */
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #f9f9f9;
|
||||
min-width: 160px;
|
||||
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-content form {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.accbtn:hover {
|
||||
background-color: #b4adad
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
color: black;
|
||||
padding: 12px 16px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-content a:hover {
|
||||
background-color: #b4adad
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropbtn {
|
||||
background-color: #177ee6;
|
||||
}
|
||||
|
||||
|
||||
|
||||
input[type=text] {
|
||||
border: none;
|
||||
border-bottom: 1px solid #b1b1b1;
|
||||
font-size: inherit;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
select {
|
||||
border: none;
|
||||
border-bottom: 1px solid #b1b1b1;
|
||||
font-size: inherit;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #212427;
|
||||
padding: 20px;
|
||||
width: 275px;
|
||||
height: 100%;
|
||||
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
bottom: 4rem;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
nav button.collapsible {
|
||||
background-color: inherit;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 18px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
nav button.active, .collapsible:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav button.collapsible:after {
|
||||
content: '\002B';
|
||||
color: white;
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
nav button.active:after {
|
||||
content: "\2212";
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav div.content {
|
||||
padding: 0px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease-out;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0px;
|
||||
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
nav ul li:hover {
|
||||
border-left: 3px solid #73bcf7;
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
nav ul li.active {
|
||||
border-left: 3px solid #73bcf7;
|
||||
}
|
||||
|
||||
nav a {
|
||||
|
||||
color: #177ee6;
|
||||
text-decoration: none;
|
||||
|
||||
}
|
||||
|
||||
nav a:visited {
|
||||
|
||||
color: #177ee6;
|
||||
text-decoration: none;
|
||||
|
||||
}
|
200
app/project-static/content.css
Normal file
200
app/project-static/content.css
Normal file
@ -0,0 +1,200 @@
|
||||
main section a {
|
||||
|
||||
color: #177ee6;
|
||||
text-decoration: none;
|
||||
|
||||
}
|
||||
|
||||
main section a:visited {
|
||||
|
||||
color: #177ee6;
|
||||
text-decoration: none;
|
||||
|
||||
}
|
||||
|
||||
article div {
|
||||
/* background-color: #ff0000; */
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
/* display: block; */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
article div form {
|
||||
/* background-color: #00ff00; */
|
||||
display: block;
|
||||
/* text-align: center; */
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
/* justify-content: center; */
|
||||
/* display: block; */
|
||||
/* flex-wrap: wrap; */
|
||||
/* for horizontal aligning of child divs */
|
||||
justify-content: center;
|
||||
/* for vertical aligning */
|
||||
/* align-items: center; */
|
||||
width: 650px;
|
||||
}
|
||||
|
||||
article div form div {
|
||||
/* background-color: #0000ff; */
|
||||
/* display:inline-flexbox; */
|
||||
/* width: 100%; */
|
||||
/* display: block; */
|
||||
width: 650px;
|
||||
line-height: 30px;
|
||||
/* align-items: center; */
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
|
||||
|
||||
article div form div label {
|
||||
width: 100px;
|
||||
/* display: block; */
|
||||
}
|
||||
|
||||
article div form div input {
|
||||
width: 200px;
|
||||
/* display: block; */
|
||||
}
|
||||
|
||||
|
||||
input[type=button] {
|
||||
color: #fff;
|
||||
background-color: blue;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
visibility: hidden;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
input[type=checkbox]:after,
|
||||
input[type=checkbox]::after {
|
||||
content: " ";
|
||||
background-color: blue;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
width: 30px;
|
||||
visibility: visible;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked:after,
|
||||
input[type=checkbox]:checked::after {
|
||||
content: "\2714";
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
color: #fff;
|
||||
background-color: blue;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Style the tab */
|
||||
.tab {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #ccc;
|
||||
/* background-color: #f1f1f1; */
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Style the buttons that are used to open the tab content */
|
||||
.tab button {
|
||||
display: inline;
|
||||
background-color: inherit;
|
||||
float: left;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 14px 16px;
|
||||
transition: 0.3s;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Change background color of buttons on hover */
|
||||
.tab button:hover {
|
||||
/* background-color: #ddd; */
|
||||
border-bottom: 3px solid #ccc;
|
||||
}
|
||||
|
||||
/* Create an active/current tablink class */
|
||||
.tab button.active {
|
||||
/* background-color: #ccc; */
|
||||
border-bottom: 3px solid #177ee6;
|
||||
}
|
||||
|
||||
/* Style the tab content */
|
||||
.tabcontent {
|
||||
width: 100%;
|
||||
display: none;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
td {
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
section .content-header {
|
||||
display: flex;
|
||||
/* background-color: #08f008; */
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: space-evenly;
|
||||
padding: 0px;
|
||||
min-height: 120px;
|
||||
|
||||
}
|
||||
|
||||
section .content-header fieldset {
|
||||
/* background-color: #0851f0; */
|
||||
width: 350px;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
|
||||
}
|
||||
|
||||
section .content-header fieldset label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
|
||||
section .content-header fieldset input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
8
app/pytest.ini
Normal file
8
app/pytest.ini
Normal file
@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = app.settings
|
||||
# -- recommended but optional:
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
log_cli = 1
|
||||
log_cli_level = INFO
|
||||
log_cli_format = %(asctime)s %(levelname)s %(message)s
|
||||
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
60
app/templates/base.html.j2
Normal file
60
app/templates/base.html.j2
Normal file
@ -0,0 +1,60 @@
|
||||
{% load settings_value %}
|
||||
<html>
|
||||
|
||||
<head>
|
||||
{% load static %}
|
||||
<title>CSS Template</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'content.css' %}">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
|
||||
<h1><a href="/" style="text-decoration: none; color: inherit;">{% settings_value "SITE_TITLE" %}</a></h1>
|
||||
|
||||
<div class="dropdown" style="right: 0px; position: fixed; padding-right: 50px;">
|
||||
<button class="dropbtn">{% block user_name %}{%if user.username %}{{ user.username }}{% else %}My Account{% endif %}{% endblock %}</button>
|
||||
<div class="dropdown-content">
|
||||
<!-- <a href="#">Link 1</a> -->
|
||||
{% if user.is_superuser or user.is_staff %}
|
||||
<form action="{% url 'admin:index' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="accbtn">Admin Panel</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="{% url 'password_change' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="accbtn">Change Password</button>
|
||||
</form>
|
||||
<form action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="accbtn">Log Out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<nav style="padding: 0px;">
|
||||
{% include 'navigation.html.j2' %}
|
||||
</nav>
|
||||
<section>
|
||||
<h2>{% block title %}Page Title{% endblock %}{% block content_header_icon %}<span title="View History" id="content_header_icon">H</span>{% endblock %}</h2>
|
||||
<article>
|
||||
{% block body%}{% endblock %}
|
||||
</article>
|
||||
<footer>{% block footer%}{% endblock %}</footer>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
13
app/templates/form.html.j2
Normal file
13
app/templates/form.html.j2
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}{{ content_title }}{% endblock %}
|
||||
|
||||
{% block body%}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
5
app/templates/home.html.j2
Normal file
5
app/templates/home.html.j2
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Home{% endblock %}
|
||||
|
||||
{% block body%}{% endblock %}
|
29
app/templates/navigation.html.j2
Normal file
29
app/templates/navigation.html.j2
Normal file
@ -0,0 +1,29 @@
|
||||
{% block navigation %}
|
||||
|
||||
{% for group in nav_items %}
|
||||
<button class="collapsible{% if group.is_active %} active{% endif %}">{{ group.name }}</button>
|
||||
<div class="content"{% if group.is_active %} style="max-height:inherit" {% endif %}>
|
||||
<ul>
|
||||
{% for group_urls in group.urls %}
|
||||
<li{% if group_urls.is_active %} class="active"{% endif %}><a href="{{ group_urls.url }}">{{ group_urls.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<script>
|
||||
var coll = document.getElementsByClassName("collapsible");
|
||||
var i;
|
||||
|
||||
for (i = 0; i < coll.length; i++) {
|
||||
coll[i].addEventListener("click", function () {
|
||||
this.classList.toggle("active");
|
||||
var content = this.nextElementSibling;
|
||||
if (content.style.maxHeight) {
|
||||
content.style.maxHeight = null;
|
||||
} else {
|
||||
content.style.maxHeight = content.scrollHeight + "px";
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
13
app/templates/password_change.html.j2
Normal file
13
app/templates/password_change.html.j2
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block body%}
|
||||
<div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
32
app/templates/registration/login.html
Normal file
32
app/templates/registration/login.html
Normal file
@ -0,0 +1,32 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
{% load static %}
|
||||
<title>Login</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'content.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'data.css' %}">
|
||||
|
||||
</head>
|
||||
|
||||
<body style="background-color: #393f44">
|
||||
|
||||
<div style="height: 100%; width: 100%;">
|
||||
|
||||
<div style="display: block; text-align: center; inline-size: auto; margin: auto; background-color: #26292d; margin-top: -185px; height: 370px; margin-left: -272px; width: 544px; position: absolute; top: 50%; left: 50%; padding: 40px">
|
||||
<form method="post" style="display: inline-block; margin: auto; background-color: inherit; width: 100%; height: 100%; line-height: 30px; color: #f0f0f0;">
|
||||
{% csrf_token %}
|
||||
<fieldset style="border: none; display: unset; width: 100%; padding: 0px; margin: 0px;"><label for="id_username" style="background-color: inherit; width: 100%; display: block; padding-top: 30px; text-align: left;" >User Name</label><input name="username" autofocus autocapitalize="none" autocomplete="username" maxlength="150" required id="id_username" style="color: inherit; border-radius: 5px; background-color: #393f44; border: none; border-bottom: 1px solid #999; font-size: inherit; width: 100%; line-height: inherit;" type="text" /></fieldset>
|
||||
<fieldset style="border: none; display: unset; width: 100%; padding: 0px; margin: 0px;"><label for="id_password" style="background-color: inherit; width: 100%; display: block; padding-top: 30px; text-align: left;">Password</label><input name="password" autocomplete="current-password" required id="id_password" style="color: inherit; border-radius: 5px; background-color: #393f44; border: none; border-bottom: 1px solid #999; font-size: inherit; width: 100%; line-height: inherit;" type="password" /></fieldset>
|
||||
<button style="border-radius: 5px; background-color: #4CAF50; border: none; display: unset; width: 100%; padding: 0px; margin: 0px; margin-top: 30px; font-size: inherit; height: 35px;;" type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
69
dockerfile
Normal file
69
dockerfile
Normal file
@ -0,0 +1,69 @@
|
||||
FROM python:3.11-alpine3.19 as build
|
||||
|
||||
|
||||
RUN apk add --update \
|
||||
bash \
|
||||
git \
|
||||
gcc \
|
||||
cmake \
|
||||
libc-dev \
|
||||
alpine-sdk \
|
||||
libffi-dev \
|
||||
build-base \
|
||||
curl-dev \
|
||||
libxml2-dev \
|
||||
gettext
|
||||
|
||||
RUN pip install --upgrade \
|
||||
setuptools \
|
||||
wheel \
|
||||
setuptools-rust \
|
||||
twine
|
||||
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
|
||||
RUN mkdir -p /tmp/python_modules /tmp/python_builds
|
||||
|
||||
|
||||
RUN cd /tmp/python_modules \
|
||||
&& pip download --dest . --check-build-dependencies \
|
||||
-r /tmp/requirements.txt
|
||||
|
||||
|
||||
RUN cd /tmp/python_modules \
|
||||
# && export PATH=$PATH:~/.cargo/bin \
|
||||
&& echo "[DEBUG] PATH=$PATH" \
|
||||
&& ls -l; \
|
||||
pip wheel --wheel-dir /tmp/python_builds --find-links . *.whl; \
|
||||
pip wheel --wheel-dir /tmp/python_builds --find-links . *.tar.gz || true;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM python:3.11-alpine3.19
|
||||
|
||||
COPY requirements.txt requirements.txt
|
||||
COPY requirements_test.txt requirements_test.txt
|
||||
|
||||
|
||||
COPY ./app/. app
|
||||
|
||||
COPY --from=build /tmp/python_builds /tmp/python_builds
|
||||
|
||||
RUN pip install /tmp/python_builds/*.*; \
|
||||
python /app/manage.py collectstatic --noinput; \
|
||||
rm -rf /tmp/python_builds;
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
0
docs/articles/index.md
Normal file
0
docs/articles/index.md
Normal file
0
docs/contact.md
Normal file
0
docs/contact.md
Normal file
0
docs/index.md
Normal file
0
docs/index.md
Normal file
0
docs/operations/index.md
Normal file
0
docs/operations/index.md
Normal file
54
docs/projects/django-template/index.md
Normal file
54
docs/projects/django-template/index.md
Normal file
@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Django Template
|
||||
description: No Fuss Computings NetBox Django Site Template
|
||||
date: 2024-04-06
|
||||
template: project.html
|
||||
about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/django_app
|
||||
---
|
||||
|
||||
This Django Project is designed to be a base template for Django applications. It's intent is to contain only the minimal functionality that is/would be common to all Django applications. for instance: base templates, auth and the functions required to make the site navigable. Currently the template style is that of the Red Hat echo system (AWX, Foreman, EDA, Cockpit etc).
|
||||
|
||||
This template has built into it multi-tenancy which can easily added to your django application if using this template.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- [Multi-Tenancy](permissions.md)
|
||||
|
||||
- Auto-Generated Navigation Menu
|
||||
|
||||
|
||||
## Adding an Application
|
||||
|
||||
1. Install the django application with `pip <app-name>`
|
||||
|
||||
1. Update `app.settings.py`
|
||||
|
||||
``` python
|
||||
|
||||
INSTALLED_APPS = [
|
||||
|
||||
'<app name>.apps.<apps.py Class Name>', # Within project directory
|
||||
|
||||
'<app name>', # not in project directory
|
||||
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
1. Update `itsm/urls.py`
|
||||
|
||||
``` python
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
path("<url path>/", include("<app name>.urls")),
|
||||
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
!!! tip
|
||||
No url from the application will be visible without including the `name` parameter when calling the `path` function within the applications `url.py`. i.e. `urlpatterns[].path(name='<Navigation Name>')`. This is by design and when combined with a prefix of `_` provides the option to limit what URL's are displayed within the navigation menu. A name beginning with an underscore `_` will not be displayed in the menu.
|
||||
|
||||
Once you have completed the above list, your application will display collapsed within the navigation menu with the name of your application.
|
91
docs/projects/django-template/permissions.md
Normal file
91
docs/projects/django-template/permissions.md
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Permissions
|
||||
description: No Fuss Computings Django Template Permissions
|
||||
date: 2024-05-12
|
||||
template: project.html
|
||||
about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/django_app
|
||||
---
|
||||
|
||||
The base django permissions have not been modified with this app providing Multi-Tenancy. This is done by a mixin, that checks if the item is apart of an organization, if it is; confirmation is made that the user is part of the same organization and as long as they have the correct permission within the organization, access is granted.
|
||||
|
||||
|
||||
## How it works
|
||||
|
||||
The overall permissions system of django has not been modified with it remaining fully functional. The multi-tenancy has been setup based off of an organization with teams. A team to the underlying django system is an extension of the django auth group and for every team created a django auth group is created. THe group name is set using the following format: `<organization>_<team name>` and contains underscores `_` instead of spaces.
|
||||
|
||||
A User who is added to an team as a "Manager" can modify the team members or if they have permission `access.change_team` which also allows the changing of team permissions. Modification of an organization can be done by the django administrator (super user) or any user with permission `access._change_organization`.
|
||||
|
||||
Items can be set as `Global`, meaning that all users who have the correct permission regardless of organization will be able to take action against the object.
|
||||
|
||||
Permissions that can be modified for a team have been limited to application permissions only unless adjust the permissions from the django admin site.
|
||||
|
||||
## Multi-Tenancy workflow
|
||||
|
||||
The workflow is conducted as part of the view and has the following flow:
|
||||
|
||||
1. Checks if user is member of organization the object the action is being performed on. Will also return true if the object has field `is_global` set to `true`.
|
||||
|
||||
1. Fetches all teams the user is part of.
|
||||
|
||||
1. obtains all permissions that are linked to the team.
|
||||
|
||||
1. checks if user has the required permission for the action.
|
||||
|
||||
1. confirms that the team the permission came from is part of the same organization as the object the action is being conducted on.
|
||||
|
||||
1. ONLY on success of the above items, grants access.
|
||||
|
||||
|
||||
## Tenancy Setup
|
||||
|
||||
Within your view class include the mixin class `OrganizationPermission`, ensuring that you set the `permission_required` attribute.
|
||||
|
||||
|
||||
### Model Setup
|
||||
|
||||
Any item you wish to be multi-tenant, ensure within your model you include the tenancy model abstract class. The class includes a field called `organization` which links directly to the organization model and is used by the tenancy permission check.
|
||||
|
||||
``` python title="<your app name>/models.py"
|
||||
|
||||
from access.models import TenancyObject
|
||||
|
||||
class YourObject(TenancyObject):
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
|
||||
### View Setup
|
||||
|
||||
The mixin inlcuded in this template `OrganizationPermission` is designed to work with all django built in views and is what does the multi-tenancy permission checks.
|
||||
|
||||
``` python title="<your app name>/views.py"
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from access.mixins import OrganizationPermission
|
||||
|
||||
|
||||
class IndexView(OrganizationPermission, generic.ListView):
|
||||
|
||||
model = YourModel
|
||||
|
||||
permission_required = 'access.view_organization'
|
||||
|
||||
# Use this for static success url
|
||||
success_url = f"/organization/" + pk_url_kwarg
|
||||
|
||||
|
||||
# Use this to build dynamic success URL
|
||||
def get_success_url(self, **kwargs):
|
||||
|
||||
return f"/organization/{self.kwargs['pk']}/"
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
return MyModel.objects.filter(Q(organization__in=self.user_organizations()) | Q(is_global = True))
|
||||
|
||||
```
|
||||
|
||||
Using a filter `pk__in=self.user_organizations()` for the queryset using the mixins function `user_organizations`, will limit the query set to only items where the user is a member of the organization.
|
34
docs/projects/django-template/template.md
Normal file
34
docs/projects/django-template/template.md
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Template
|
||||
description: No Fuss Computings Django Template Jinja TEmplate
|
||||
date: 2024-06-14
|
||||
template: project.html
|
||||
about: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/django_app
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Template
|
||||
|
||||
The base template includes blocks that are designed to assist in rendering your content. The following blocks are available:
|
||||
|
||||
- `title` - The page and title
|
||||
|
||||
- `content_header_icon` - Header icon that is middle aligned with the page title, floating right.
|
||||
|
||||
- `body` - The html content of the page
|
||||
|
||||
``` html title="template.html.j2"
|
||||
|
||||
{% extends 'base.html.j2' %}
|
||||
|
||||
{% block title %}{% endblock %}
|
||||
{% block content_header_icon %}<span title="View History" id="content_header_icon">H</span>{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
your content here
|
||||
|
||||
{% endblock %}
|
||||
|
||||
```
|
0
docs/projects/index.md
Normal file
0
docs/projects/index.md
Normal file
0
docs/tags.md
Normal file
0
docs/tags.md
Normal file
1
gitlab-ci
Submodule
1
gitlab-ci
Submodule
Submodule gitlab-ci added at a24f352ca3
33
mkdocs.yml
Normal file
33
mkdocs.yml
Normal file
@ -0,0 +1,33 @@
|
||||
INHERIT: website-template/mkdocs.yml
|
||||
|
||||
docs_dir: 'docs'
|
||||
|
||||
repo_name: Django Template
|
||||
repo_url: https://gitlab.com/nofusscomputing/infrastructure/configuration-management/django_app
|
||||
edit_uri: '/-/ide/project/nofusscomputing/infrastructure/configuration-management/django_app/edit/development/-/docs/'
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
|
||||
- Articles:
|
||||
|
||||
- articles/index.md
|
||||
|
||||
- Projects:
|
||||
|
||||
- projects/index.md
|
||||
|
||||
- Django Template:
|
||||
|
||||
- projects/django-template/index.md
|
||||
|
||||
- projects/django-template/permissions.md
|
||||
|
||||
- projects/django-template/template.md
|
||||
|
||||
- Operations:
|
||||
|
||||
- operations/index.md
|
||||
|
||||
- Contact Us: contact.md
|
||||
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Django==5.0.6
|
||||
django-debug-toolbar==4.3.0
|
||||
social-auth-app-django==5.4.1
|
||||
|
||||
djangorestframework==3.15.1
|
||||
djangorestframework-jsonapi==7.0.0
|
||||
|
||||
# DRF
|
||||
pyyaml==6.0.1
|
||||
django-filter==24.2
|
4
requirements_test.txt
Normal file
4
requirements_test.txt
Normal file
@ -0,0 +1,4 @@
|
||||
pytest==8.2.0
|
||||
pytest-django==4.8.0
|
||||
coverage==7.5.1
|
||||
pytest-cov==5.0.0
|
1
website-template
Submodule
1
website-template
Submodule
Submodule website-template added at f5a82d3604
Reference in New Issue
Block a user