Merge branch 'genesis' into 'development'

feat: Genesis

See merge request nofusscomputing/projects/django_template!1
This commit is contained in:
2024-05-15 03:11:56 +00:00
73 changed files with 2970 additions and 0 deletions

8
.cz.yaml Normal file
View 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
View File

@ -0,0 +1,12 @@
.git
.git*
website-template/
gitlab-ci/
venv/
docs/
**/*.sqlite3
**/static/
__pycache__
**__pycache__
**.pyc
** .pytest*

6
.gitignore vendored
View File

@ -1 +1,7 @@
venv/**
*/static/**
__pycache__
**db.sqlite3
**.coverage
artifacts/
**itam/

53
.gitlab-ci.yml Normal file
View 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

View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"gitlab.aiAssistedCodeSuggestions.enabled": false,
"gitlab.duoChat.enabled": false,
"cSpell.enableFiletypes": [
"!python"
],
}

58
CONTRIBUTING.md Normal file
View 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
View 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
View File

30
app/access/admin.py Normal file
View 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
View 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
View 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

View 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'],
},
),
]

View File

147
app/access/mixin.py Normal file
View 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
View 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()

View 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 %}

View 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 %}

View 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>&nbsp;</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 %}

View 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

View 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
View 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"),
]

View 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
View 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
View 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
View File

6
app/api/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View 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
View 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
View 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
View 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
View File

16
app/app/asgi.py Normal file
View 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()

View 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
View 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'
}

View 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

View 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
View 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'),
]

View File

@ -0,0 +1 @@
from .home import *

18
app/app/views/home.py Normal file
View 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
View 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
View File

6
app/core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

View File

View 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
View 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
View 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;
}

View 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
View 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

View 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>

View 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 %}

View File

@ -0,0 +1,5 @@
{% extends 'base.html.j2' %}
{% block title %}Home{% endblock %}
{% block body%}{% endblock %}

View 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 %}

View 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 %}

View 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
View 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
View File

0
docs/contact.md Normal file
View File

0
docs/index.md Normal file
View File

0
docs/operations/index.md Normal file
View File

View 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.

View 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.

View 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
View File

0
docs/tags.md Normal file
View File

1
gitlab-ci Submodule

Submodule gitlab-ci added at a24f352ca3

33
mkdocs.yml Normal file
View 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
View 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
View 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

Submodule website-template added at f5a82d3604