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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/base-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- 'docker/DispatcharrBase'
- '.github/workflows/base-image.yml'
pull_request:
branches: [ main, dev ]
branches: [ main, dev, user-management ]
paths:
- 'docker/DispatcharrBase'
- '.github/workflows/base-image.yml'
Expand Down Expand Up @@ -70,7 +70,7 @@ jobs:
context: .
file: ./docker/DispatcharrBase
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
tags: |
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:base
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:base-${{ steps.timestamp.outputs.timestamp }}
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
tags: |
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.meta.outputs.branch_tag }}
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.version.outputs.version }}-${{ steps.timestamp.outputs.timestamp }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- dev
- user-management

jobs:
build-and-push:
Expand Down Expand Up @@ -47,7 +48,7 @@ jobs:
context: docker
file: docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
build-args: |
BRANCH=${{ env.BRANCH }}
REPO_URL=https://github.com/${{ github.repository }}
Expand Down
36 changes: 16 additions & 20 deletions apps/accounts/api_urls.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
AuthViewSet, UserViewSet, GroupViewSet,
list_permissions, initialize_superuser
AuthViewSet,
UserViewSet,
GroupViewSet,
list_permissions,
initialize_superuser,
)
from rest_framework_simplejwt import views as jwt_views

app_name = 'accounts'
app_name = "accounts"

# 🔹 Register ViewSets with a Router
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'groups', GroupViewSet, basename='group')
router.register(r"users", UserViewSet, basename="user")
router.register(r"groups", GroupViewSet, basename="group")

# 🔹 Custom Authentication Endpoints
auth_view = AuthViewSet.as_view({
'post': 'login'
})
auth_view = AuthViewSet.as_view({"post": "login"})

logout_view = AuthViewSet.as_view({
'post': 'logout'
})
logout_view = AuthViewSet.as_view({"post": "logout"})

# 🔹 Define API URL patterns
urlpatterns = [
# Authentication
path('auth/login/', auth_view, name='user-login'),
path('auth/logout/', logout_view, name='user-logout'),

path("auth/login/", auth_view, name="user-login"),
path("auth/logout/", logout_view, name="user-logout"),
# Superuser API
path('initialize-superuser/', initialize_superuser, name='initialize_superuser'),

path("initialize-superuser/", initialize_superuser, name="initialize_superuser"),
# Permissions API
path('permissions/', list_permissions, name='list-permissions'),

path('token/', jwt_views.TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'),
path("permissions/", list_permissions, name="list-permissions"),
path("token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
]

# 🔹 Include ViewSet routes
Expand Down
78 changes: 54 additions & 24 deletions apps/accounts/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@
from django.contrib.auth.models import Group, Permission
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import viewsets
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
import json
from .permissions import ReadOnly, IsAdmin

from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer


@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists.
def initialize_superuser(request):
Expand All @@ -26,15 +30,20 @@ def initialize_superuser(request):
password = data.get("password")
email = data.get("email", "")
if not username or not password:
return JsonResponse({"error": "Username and password are required."}, status=400)
return JsonResponse(
{"error": "Username and password are required."}, status=400
)
# Create the superuser
User.objects.create_superuser(username=username, password=password, email=email)
User.objects.create_superuser(
username=username, password=password, email=email, user_level=10
)
return JsonResponse({"superuser_exists": True})
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
# For GET requests, indicate no superuser exists
return JsonResponse({"superuser_exists": False})


# 🔹 1) Authentication APIs
class AuthViewSet(viewsets.ViewSet):
"""Handles user login and logout"""
Expand All @@ -43,36 +52,40 @@ class AuthViewSet(viewsets.ViewSet):
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['username', 'password'],
required=["username", "password"],
properties={
'username': openapi.Schema(type=openapi.TYPE_STRING),
'password': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD)
"username": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(
type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD
),
},
),
responses={200: "Login successful", 400: "Invalid credentials"},
)
def login(self, request):
"""Logs in a user and returns user details"""
username = request.data.get('username')
password = request.data.get('password')
username = request.data.get("username")
password = request.data.get("password")
user = authenticate(request, username=username, password=password)

if user:
login(request, user)
return Response({
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list('name', flat=True))
return Response(
{
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list("name", flat=True)),
},
}
})
)
return Response({"error": "Invalid credentials"}, status=400)

@swagger_auto_schema(
operation_description="Log out the current user",
responses={200: "Logout successful"}
responses={200: "Logout successful"},
)
def logout(self, request):
"""Logs out the authenticated user"""
Expand All @@ -83,13 +96,19 @@ def logout(self, request):
# 🔹 2) User Management APIs
class UserViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Users"""

queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]

def get_permissions(self):
if self.action == "me":
return [IsAuthenticated()]

return [IsAdmin()]

@swagger_auto_schema(
operation_description="Retrieve a list of users",
responses={200: UserSerializer(many=True)}
responses={200: UserSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
Expand All @@ -110,17 +129,28 @@ def update(self, request, *args, **kwargs):
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)

@swagger_auto_schema(
method="get",
operation_description="Get active user information",
)
@action(detail=False, methods=["get"], url_path="me")
def me(self, request):
user = request.user
serializer = UserSerializer(user)
return Response(serializer.data)


# 🔹 3) Group Management APIs
class GroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Groups"""

queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated]

@swagger_auto_schema(
operation_description="Retrieve a list of groups",
responses={200: GroupSerializer(many=True)}
responses={200: GroupSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
Expand All @@ -144,11 +174,11 @@ def destroy(self, request, *args, **kwargs):

# 🔹 4) Permissions List API
@swagger_auto_schema(
method='get',
method="get",
operation_description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)}
responses={200: PermissionSerializer(many=True)},
)
@api_view(['GET'])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def list_permissions(request):
"""Returns a list of all available permissions"""
Expand Down
5 changes: 3 additions & 2 deletions apps/accounts/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.apps import AppConfig


class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.accounts'
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
verbose_name = "Accounts & Authentication"
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 5.1.6 on 2025-05-18 15:47

from django.db import migrations, models


def set_user_level_to_10(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.update(user_level=10)


class Migration(migrations.Migration):

dependencies = [
("accounts", "0001_initial"),
("dispatcharr_channels", "0021_channel_user_level"),
]

operations = [
migrations.RemoveField(
model_name="user",
name="channel_groups",
),
migrations.AddField(
model_name="user",
name="channel_profiles",
field=models.ManyToManyField(
blank=True,
related_name="users",
to="dispatcharr_channels.channelprofile",
),
),
migrations.AddField(
model_name="user",
name="user_level",
field=models.IntegerField(default=0),
),
migrations.RunPython(set_user_level_to_10),
]
14 changes: 11 additions & 3 deletions apps/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@
from django.db import models
from django.contrib.auth.models import AbstractUser, Permission


class User(AbstractUser):
"""
Custom user model for Dispatcharr.
Inherits from Django's AbstractUser to add additional fields if needed.
"""

class UserLevel(models.IntegerChoices):
STREAMER = 0, "Streamer"
READ_ONLY = 1, "ReadOnly"
ADMIN = 10, "Admin"

avatar_config = models.JSONField(default=dict, blank=True, null=True)
channel_groups = models.ManyToManyField(
'dispatcharr_channels.ChannelGroup', # Updated reference to renamed model
channel_profiles = models.ManyToManyField(
"dispatcharr_channels.ChannelProfile",
blank=True,
related_name="users"
related_name="users",
)
user_level = models.IntegerField(default=UserLevel.STREAMER)

def __str__(self):
return self.username
Expand Down
38 changes: 38 additions & 0 deletions apps/accounts/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from rest_framework.permissions import BasePermission, IsAuthenticated
from .models import User


class ReadOnly(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.user_level >= User.UserLevel.READ_ONLY


class IsAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.user_level >= 10


class IsOwnerOfObject(BasePermission):
def has_object_permission(self, request, view, obj):
is_admin = IsAdmin().has_permission(request, view)
is_owner = request.user in obj.users.all()

return is_admin or is_owner


permission_classes_by_action = {
"list": [ReadOnly],
"create": [IsAdmin],
"retrieve": [ReadOnly],
"update": [IsAdmin],
"partial_update": [IsAdmin],
"destroy": [IsAdmin],
}

permission_classes_by_method = {
"GET": [ReadOnly],
"POST": [IsAdmin],
"PATCH": [IsAdmin],
"PUT": [IsAdmin],
"DELETE": [IsAdmin],
}
Loading