service listing

This commit is contained in:
Tobias Brunner 2025-01-27 14:58:23 +01:00
parent ea44f6f54a
commit b367012d5c
No known key found for this signature in database
22 changed files with 615 additions and 7 deletions

View file

@ -1,6 +0,0 @@
def main():
print("Hello from servala-fe!")
if __name__ == "__main__":
main()

BIN
hub/db.sqlite3 Normal file

Binary file not shown.

0
hub/hub/__init__.py Normal file
View file

16
hub/hub/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for hub 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.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub.settings")
application = get_asgi_application()

112
hub/hub/settings.py Normal file
View file

@ -0,0 +1,112 @@
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.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-$5nkma6cv^a58n4%4$nef2tp8u!2vt=qbhoog5waui0iwe+8yp"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"services",
]
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",
]
ROOT_URLCONF = "hub.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"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",
],
},
},
]
WSGI_APPLICATION = "hub.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.1/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",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/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.1/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

7
hub/hub/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("services.urls")),
]

16
hub/hub/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for hub 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.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub.settings")
application = get_wsgi_application()

22
hub/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", "hub.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()

0
hub/services/__init__.py Normal file
View file

28
hub/services/admin.py Normal file
View file

@ -0,0 +1,28 @@
from django.contrib import admin
from .models import CloudProvider, Country, ServiceLevel, Service
@admin.register(CloudProvider)
class CloudProviderAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
@admin.register(Country)
class CountryAdmin(admin.ModelAdmin):
list_display = ("name", "code")
search_fields = ("name", "code")
@admin.register(ServiceLevel)
class ServiceLevelAdmin(admin.ModelAdmin):
list_display = ("name", "response_time")
search_fields = ("name",)
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
list_display = ("name", "cloud_provider", "service_level", "price")
list_filter = ("cloud_provider", "service_level", "countries")
search_fields = ("name", "description")
filter_horizontal = ("countries",)

6
hub/services/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ServicesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "services"

View file

@ -0,0 +1,101 @@
# Generated by Django 5.1.5 on 2025-01-27 12:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="CloudProvider",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
],
),
migrations.CreateModel(
name="Country",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("code", models.CharField(max_length=2)),
],
options={
"verbose_name_plural": "Countries",
},
),
migrations.CreateModel(
name="ServiceLevel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("description", models.TextField()),
("response_time", models.CharField(max_length=50)),
],
),
migrations.CreateModel(
name="Service",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200)),
("description", models.TextField()),
("price", models.DecimalField(decimal_places=2, max_digits=10)),
("features", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"cloud_provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="services.cloudprovider",
),
),
("countries", models.ManyToManyField(to="services.country")),
(
"service_level",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="services.servicelevel",
),
),
],
),
]

View file

44
hub/services/models.py Normal file
View file

@ -0,0 +1,44 @@
from django.db import models
class CloudProvider(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
def __str__(self):
return self.name
class Country(models.Model):
name = models.CharField(max_length=100)
code = models.CharField(max_length=2)
class Meta:
verbose_name_plural = "Countries"
def __str__(self):
return self.name
class ServiceLevel(models.Model):
name = models.CharField(max_length=100)
description = models.TextField()
response_time = models.CharField(max_length=50)
def __str__(self):
return self.name
class Service(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
cloud_provider = models.ForeignKey(CloudProvider, on_delete=models.CASCADE)
service_level = models.ForeignKey(ServiceLevel, on_delete=models.CASCADE)
countries = models.ManyToManyField(Country)
price = models.DecimalField(max_digits=10, decimal_places=2)
features = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Services Marketplace</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'services:service_list' %}">Services Marketplace</a>
</div>
</nav>
<div class="container mt-4">
{% block content %}
{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,40 @@
{% extends 'services/base.html' %}
{% block content %}
<div class="card">
<div class="card-body">
<h2 class="card-title">{{ service.name }}</h2>
<h6 class="card-subtitle mb-3 text-muted">{{ service.cloud_provider.name }}</h6>
<div class="row mb-4">
<div class="col-md-8">
<h5>Description</h5>
<p>{{ service.description }}</p>
<h5>Features</h5>
<p>{{ service.features|linebreaks }}</p>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Service Details</h5>
<p><strong>Price:</strong> ${{ service.price }}</p>
<p><strong>Service Level:</strong> {{ service.service_level.name }}</p>
<p><strong>Response Time:</strong> {{ service.service_level.response_time }}</p>
<h6>Available Countries</h6>
<ul>
{% for country in service.countries.all %}
<li>{{ country.name }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
<a href="{% url 'services:service_list' %}" class="btn btn-secondary">Back to Services</a>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,89 @@
{% extends 'services/base.html' %}
{% block content %}
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Filters</h5>
<form method="get">
<div class="mb-3">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
value="{{ request.GET.search }}">
</div>
<div class="mb-3">
<label for="cloud_provider" class="form-label">Cloud Provider</label>
<select class="form-select" id="cloud_provider" name="cloud_provider">
<option value="">All Providers</option>
{% for provider in cloud_providers %}
<option value="{{ provider.id }}" {% if request.GET.cloud_provider == provider.id|stringformat:"i" %}selected{% endif %}>
{{ provider.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="country" class="form-label">Country</label>
<select class="form-select" id="country" name="country">
<option value="">All Countries</option>
{% for country in countries %}
<option value="{{ country.id }}" {% if request.GET.country == country.id|stringformat:"i" %}selected{% endif %}>
{{ country.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="service_level" class="form-label">Service Level</label>
<select class="form-select" id="service_level" name="service_level">
<option value="">All Levels</option>
{% for level in service_levels %}
<option value="{{ level.id }}" {% if request.GET.service_level == level.id|stringformat:"i" %}selected{% endif %}>
{{ level.name }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Apply Filters</button>
<a href="{% url 'services:service_list' %}" class="btn btn-secondary">Clear</a>
</form>
</div>
</div>
</div>
<div class="col-md-9">
<div class="row row-cols-1 row-cols-md-2 g-4">
{% for service in services %}
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ service.name }}</h5>
<h6 class="card-subtitle mb-2 text-muted">{{ service.cloud_provider.name }}</h6>
<p class="card-text">{{ service.description|truncatewords:30 }}</p>
<p class="card-text">
<small class="text-muted">
Service Level: {{ service.service_level.name }}<br>
Price: ${{ service.price }}
</small>
</p>
<a href="{% url 'services:service_detail' service.pk %}" class="btn btn-primary">View
Details</a>
</div>
</div>
</div>
{% empty %}
<div class="col">
<div class="alert alert-info">
No services found matching your criteria.
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

3
hub/services/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
hub/services/urls.py Normal file
View file

@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = "services"
urlpatterns = [
path("", views.service_list, name="service_list"),
path("service/<int:pk>/", views.service_detail, name="service_detail"),
]

39
hub/services/views.py Normal file
View file

@ -0,0 +1,39 @@
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
from .models import Service, CloudProvider, Country, ServiceLevel
def service_list(request):
services = Service.objects.all()
cloud_providers = CloudProvider.objects.all()
countries = Country.objects.all()
service_levels = ServiceLevel.objects.all()
# Filter handling
if request.GET.get("cloud_provider"):
services = services.filter(cloud_provider_id=request.GET.get("cloud_provider"))
if request.GET.get("country"):
services = services.filter(countries__id=request.GET.get("country"))
if request.GET.get("service_level"):
services = services.filter(service_level_id=request.GET.get("service_level"))
if request.GET.get("search"):
query = request.GET.get("search")
services = services.filter(
Q(name__icontains=query) | Q(description__icontains=query)
)
context = {
"services": services,
"cloud_providers": cloud_providers,
"countries": countries,
"service_levels": service_levels,
}
return render(request, "services/service_list.html", context)
def service_detail(request, pk):
service = get_object_or_404(Service, pk=pk)
return render(request, "services/service_detail.html", {"service": service})

View file

@ -4,4 +4,6 @@ version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [] dependencies = [
"django>=5.1.5",
]

54
uv.lock generated Normal file
View file

@ -0,0 +1,54 @@
version = 1
requires-python = ">=3.13"
[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
]
[[package]]
name = "django"
version = "5.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/17/834e3e08d590dcc27d4cc3c5cd4e2fb757b7a92bab9de8ee402455732952/Django-5.1.5.tar.gz", hash = "sha256:19bbca786df50b9eca23cee79d495facf55c8f5c54c529d9bf1fe7b5ea086af3", size = 10700031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/e6/e92c8c788b83d109f34d933c5e817095d85722719cb4483472abc135f44e/Django-5.1.5-py3-none-any.whl", hash = "sha256:c46eb936111fffe6ec4bc9930035524a8be98ec2f74d8a0ff351226a3e52f459", size = 8276957 },
]
[[package]]
name = "servala-fe"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
]
[package.metadata]
requires-dist = [{ name = "django", specifier = ">=5.1.5" }]
[[package]]
name = "sqlparse"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 },
]
[[package]]
name = "tzdata"
version = "2025.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 },
]