Merge pull request 'Add organization and global dashboards' (#116) from 51-login-dashboard into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 55s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 8s

Reviewed-on: #116
This commit is contained in:
Tobias Kunze 2025-06-23 09:44:28 +00:00
commit 778c1fb801
7 changed files with 526 additions and 4 deletions

View file

@ -0,0 +1,111 @@
{% extends "frontend/base.html" %}
{% load i18n static %}
{% block html_title %}
{% block page_title %}
{% translate "Welcome to Servala" %}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
{% if not user_organizations %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Welcome to Servala!" %}</h4>
</div>
<div class="card-content">
<div class="card-body text-center">
<div class="mb-4">
<i class="bi bi-building fs-1 text-primary"></i>
</div>
<h5 class="mb-3">{% translate "Get Started with Your First Organization" %}</h5>
<p class="text-muted mb-4">
{% blocktranslate trimmed %}
Organizations help you manage your services and resources.
Create your first organization to start using Servala's service catalog.
{% endblocktranslate %}
</p>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a href="{% url 'frontend:organization.create' %}"
class="btn btn-primary btn-lg">
<i class="bi bi-plus-circle me-2"></i>
{% translate "Create Organization" %}
</a>
</div>
<div class="mt-5 pt-4 border-top">
<h6 class="text-muted mb-3">{% translate "Next Steps" %}</h6>
<div class="row">
<div class="col-md-4">
<div class="text-center">
<i class="bi bi-1-circle fs-2 text-success mb-2"></i>
<h6>{% translate "Create Organization" %}</h6>
<small class="text-muted">{% translate "Set up your workspace" %}</small>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<i class="bi bi-2-circle fs-2 text-info mb-2"></i>
<h6>{% translate "Discover Services" %}</h6>
<small class="text-muted">{% translate "Browse available services" %}</small>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<i class="bi bi-3-circle fs-2 text-warning mb-2"></i>
<h6>{% translate "Create Instances" %}</h6>
<small class="text-muted">{% translate "Deploy your first service" %}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row justify-content-center">
{% for organization in user_organizations %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-2 organization-card">
<div class="card-body d-flex flex-column">
<div class="text-center mb-3">
<i class="bi bi-building fs-1 text-primary"></i>
</div>
<h5 class="card-title text-center mb-3">{{ organization.name }}</h5>
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<small class="text-muted">
<i class="bi bi-server me-1"></i>
{{ organization.instance_count }}
{% blocktranslate trimmed count count=organization.instance_count %}
instance
{% plural %}
instances
{% endblocktranslate %}
</small>
<small class="text-muted">
<i class="bi bi-people me-1"></i>
{{ organization.members.count }}
{% blocktranslate trimmed count count=organization.members.count %}
member
{% plural %}
members
{% endblocktranslate %}
</small>
</div>
</div>
<div class="mt-3">
<a href="{% url 'frontend:organization.dashboard' organization=organization.slug %}"
class="btn btn-primary w-100">{% translate "Enter Organization" %}</a>
</div>
</div>
</div>
</div>
{% endfor %}
<div class="text-center mt-4 pt-4 border-top"></div>
</div>
{% endif %}
</section>
{% endblock content %}

View file

@ -1 +1,258 @@
{% extends "frontend/base.html" %} {% extends "frontend/base.html" %}
{% load i18n static %}
{% block html_title %}
{{ object.name }} {% translate "Dashboard" %}
{% endblock html_title %}
{% block page_title %}{% endblock %}
{% block content %}
<section class="section">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h4 class="mb-2">{% translate "Welcome to" %} {{ object.name }}</h4>
<p class="text-muted mb-0">
{% if has_instances %}
{% translate "Here's an overview of your organization's services and resources." %}
{% else %}
{% translate "Ready to get started? Discover services and create your first instance." %}
{% endif %}
</p>
</div>
{% if user_role %}
<div class="text-end">
<span class="badge bg-light text-dark">
{% if user_role == "owner" %}
<i class="bi bi-star-fill me-1"></i>{% translate "Owner" %}
{% elif user_role == "admin" %}
<i class="bi bi-gear me-1"></i>{% translate "Administrator" %}
{% else %}
<i class="bi bi-person me-1"></i>{% translate "Member" %}
{% endif %}
</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% if has_instances %}
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stats-icon purple me-3">
<i class="bi bi-hdd-stack"></i>
</div>
<div>
<h6 class="text-muted font-semibold mb-0">{% translate "Service Instances" %}</h6>
<h4 class="font-extrabold mb-0 mt-1">{{ service_instances_count }}</h4>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stats-icon blue me-3">
<i class="bi bi-people"></i>
</div>
<div>
<h6 class="text-muted font-semibold mb-0">{% translate "Team Members" %}</h6>
<h4 class="font-extrabold mb-0 mt-1">{{ members_count }}</h4>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="row">
{% if has_instances %}
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Recent Service Instances" %}</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Service" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Created" %}</th>
<th>{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for instance in service_instances %}
<tr>
<td>
<a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}"
class="fw-semibold text-decoration-none">{{ instance.name }}</a>
</td>
<td>
<div class="d-flex align-items-center">
{% if instance.context.service_offering.service.logo %}
<img src="{{ instance.context.service_offering.service.logo.url }}"
alt="{{ instance.context.service_offering.service.name }}"
class="me-2"
style="width: 20px;
height: 20px;
object-fit: contain">
{% endif %}
<span class="small text-muted">{{ instance.context.service_offering.service.name }}</span>
</div>
</td>
<td>
{% if instance.is_deleted %}
<span class="badge bg-danger">{% translate "Deleted" %}</span>
{% else %}
<span class="badge bg-success">{% translate "Active" %}</span>
{% endif %}
</td>
<td>
<span class="small text-muted">{{ instance.created_at|date:"M d, Y" }}</span>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}"
class="btn btn-outline-primary btn-sm"
title="{% translate 'View Details' %}">
<i class="bi bi-eye"></i>
</a>
{% if instance.has_change_permission %}
<a href="{% url 'frontend:organization.instance.update' organization=object.slug slug=instance.name %}"
class="btn btn-outline-secondary btn-sm"
title="{% translate 'Edit' %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="text-center mt-3">
<a href="{% url 'frontend:organization.instances' organization=object.slug %}"
class="btn btn-outline-primary me-2">{% translate "View All Instances" %}</a>
<a href="{% url 'frontend:organization.services' organization=object.slug %}"
class="btn btn-primary">{% translate "Create New Instance" %}</a>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Quick Actions" %}</h4>
</div>
<div class="card-body">
<div class="list-group list-group-flush quick-actions">
<a href="{{ object.urls.services }}"
class="list-group-item list-group-item-action d-flex align-items-center">
<i class="bi h2 bi-grid-3x3-gap me-3 text-primary"></i>
<div>
<h6 class="mb-1">{% translate "Browse Services" %}</h6>
<small class="text-muted">{% translate "Explore available services" %}</small>
</div>
</a>
<a href="{{ object.urls.instances }}"
class="list-group-item list-group-item-action d-flex align-items-center">
<i class="bi h2 bi-server me-3 text-success"></i>
<div>
<h6 class="mb-1">{% translate "Manage Instances" %}</h6>
<small class="text-muted">{% translate "View and configure instances" %}</small>
</div>
</a>
<a href="{{ object.urls.details }}"
class="list-group-item list-group-item-action d-flex align-items-center">
<i class="bi h2 bi-gear me-3 text-warning"></i>
<div>
<h6 class="mb-1">{% translate "Organization Settings" %}</h6>
<small class="text-muted">{% translate "Configure organization details" %}</small>
</div>
</a>
<a href="{{ organization.urls.support }}"
class="list-group-item list-group-item-action d-flex align-items-center">
<i class="bi h2 bi-life-preserver me-3 text-info"></i>
<div>
<h6 class="mb-1">{% translate "Get Support" %}</h6>
<small class="text-muted">{% translate "Access help and documentation" %}</small>
</div>
</a>
</div>
</div>
</div>
</div>
{% else %}
{# if has_instances #}
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Get Started" %}</h4>
</div>
<div class="card-body">
<div class="text-center py-4">
<div class="mb-4">
<i class="bi bi-rocket fs-1 text-primary"></i>
</div>
<h5 class="mb-3">{% translate "Ready to deploy your first service?" %}</h5>
<p class="text-muted mb-4">
{% translate "Browse our service catalog and deploy databases, storage, and other services with just a few clicks." %}
</p>
<a href="{% url 'frontend:organization.services' organization=object.slug %}"
class="btn btn-primary btn-lg">
<i class="bi bi-search me-2"></i>
{% translate "Discover Services" %}
</a>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Next Steps" %}</h4>
</div>
<div class="card-body">
<div class="timeline">
<div class="timeline-item">
<div class="timeline-marker bg-success"></div>
<div class="timeline-content">
<h6 class="text-success">{% translate "Organization Created" %}</h6>
<small class="text-muted">{% translate "You're all set up!" %}</small>
</div>
</div>
<div class="timeline-item">
<div class="timeline-marker bg-primary"></div>
<div class="timeline-content">
<h6>{% translate "Discover Services" %}</h6>
<small class="text-muted">{% translate "Browse what's available" %}</small>
</div>
</div>
<div class="timeline-item">
<div class="timeline-marker"></div>
<div class="timeline-content">
<h6 class="text-muted">{% translate "Deploy Your First Service" %}</h6>
<small class="text-muted">{% translate "Create an instance" %}</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock content %}

View file

@ -6,6 +6,11 @@ from servala.frontend import views
urlpatterns = [ urlpatterns = [
path("accounts/profile/", views.ProfileView.as_view(), name="profile"), path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
path("accounts/logout/", views.LogoutView.as_view(), name="logout"), path("accounts/logout/", views.LogoutView.as_view(), name="logout"),
path(
"organizations/",
views.OrganizationSelectionView.as_view(),
name="organization.selection",
),
path( path(
"organizations/create", "organizations/create",
views.OrganizationCreateView.as_view(), views.OrganizationCreateView.as_view(),
@ -68,5 +73,9 @@ urlpatterns = [
] ]
), ),
), ),
path("", RedirectView.as_view(pattern_name="frontend:profile"), name="index"), path(
"",
RedirectView.as_view(pattern_name="frontend:organization.selection"),
name="index",
),
] ]

View file

@ -1,5 +1,12 @@
from .auth import LogoutView from .auth import LogoutView
from .generic import IndexView, ProfileView, custom_403, custom_404, custom_500 from .generic import (
IndexView,
OrganizationSelectionView,
ProfileView,
custom_403,
custom_404,
custom_500,
)
from .organization import ( from .organization import (
OrganizationCreateView, OrganizationCreateView,
OrganizationDashboardView, OrganizationDashboardView,
@ -21,6 +28,7 @@ __all__ = [
"LogoutView", "LogoutView",
"OrganizationCreateView", "OrganizationCreateView",
"OrganizationDashboardView", "OrganizationDashboardView",
"OrganizationSelectionView",
"OrganizationUpdateView", "OrganizationUpdateView",
"ServiceDetailView", "ServiceDetailView",
"ServiceInstanceDeleteView", "ServiceInstanceDeleteView",

View file

@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from django.shortcuts import render from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.shortcuts import redirect, render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic import TemplateView from django.views.generic import TemplateView
@ -13,6 +15,30 @@ class IndexView(TemplateView):
template_name = "frontend/index.html" template_name = "frontend/index.html"
class OrganizationSelectionView(LoginRequiredMixin, TemplateView):
template_name = "frontend/organization_selection.html"
@cached_property
def user_organizations(self):
return self.request.user.organizations.all()
def dispatch(self, request, *args, **kwargs):
# Users with a single organization get redirected to the organizations dashboard
if self.user_organizations.count() == 1:
organization = self.user_organizations.first()
return redirect(organization.urls.base)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["user_organizations"] = self.user_organizations.annotate(
instance_count=Count(
"service_instances", filter=Q(service_instances__is_deleted=False)
)
)
return context
class ProfileView(HtmxUpdateView): class ProfileView(HtmxUpdateView):
template_name = "frontend/profile.html" template_name = "frontend/profile.html"
form_class = UserProfileForm form_class = UserProfileForm

View file

@ -3,7 +3,12 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView from django.views.generic import CreateView, DetailView
from rules.contrib.views import AutoPermissionRequiredMixin from rules.contrib.views import AutoPermissionRequiredMixin
from servala.core.models import BillingEntity, Organization from servala.core.models import (
BillingEntity,
Organization,
OrganizationMembership,
ServiceInstance,
)
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
@ -61,6 +66,37 @@ class OrganizationDashboardView(
): ):
template_name = "frontend/organizations/dashboard.html" template_name = "frontend/organizations/dashboard.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
organization = self.get_object()
service_instances = ServiceInstance.objects.filter(
organization=organization, is_deleted=False
)
recent_instances = service_instances.order_by("-created_at")[:5]
for instance in recent_instances:
instance.has_change_permission = self.request.user.has_perm(
"core.change_serviceinstance", instance
)
user_membership = OrganizationMembership.objects.filter(
user=self.request.user, organization=organization
).first()
user_role = user_membership.role if user_membership else None
context.update(
{
"service_instances_count": service_instances.count(),
"service_instances": recent_instances,
"members_count": organization.members.count(),
"has_instances": service_instances.exists(),
"user_role": user_role,
}
)
return context
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
template_name = "frontend/organizations/update.html" template_name = "frontend/organizations/update.html"

View file

@ -99,3 +99,78 @@ a.btn-keycloak {
.hide-form-errors .alert.form-errors { .hide-form-errors .alert.form-errors {
display: none; display: none;
} }
.organization-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
}
.organization-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.stats-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
i::before {
display: block;
margin-top: -4px;
margin-left: -5px;
}
}
.stats-icon.purple {
background-color: var(--bs-primary);
}
.stats-icon.blue {
background-color: var(--bs-info);
}
.stats-icon.green {
background-color: var(--brand-success);
}
.timeline {
position: relative;
padding-left: 20px;
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background-color: #e5e7eb;
}
.timeline-item {
position: relative;
margin-bottom: 1rem;
}
.timeline-marker {
position: absolute;
left: -16px;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #d1d5db;
border: 3px solid white;
box-shadow: 0 0 0 3px #f3f4f6;
}
.timeline-content {
margin-left: 10px;
}
.quick-actions i.bi {
margin-top: -16px;
padding-right: 28px;
}