diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..de676cc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+.vscode/
+venv/
+db.sqlite3
+setup.cfg
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index f0d3c98..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2022 tric-itpc
-
-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.
diff --git a/README.md b/README.md
index cfb476f..847799a 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,48 @@
-# Вакансия :: Программист Python
-
-Разработка бизнес-системы с использованием веб-технологий. Автоматизация сервисов с большим количеством пользователей
-
-## От вас
-
-### Обязательно
-
-- Знание синтаксиса языка Python
-- Опыт разработки на Python не менее 1 года
-- Базовые знания принципов работы Web
-- Желание работать в команде и развиваться
-
-### Приветствуется
-
-- Навыки работы с Flask, Sanic, FastAPI
-- Опыт работы с БД: PostgreSQL, MS SQL, MongoDB, ClickHouse
-- Опыт разработки под ОС семейства GNU Linux, знание основных команд
-- Работа с системами управления исходным кодом Git
-- Знания базовых принципов разработки (тестирование, рефакторинг, Code Review, CI/CD)
-
-### Будет круто, но не обязательно
-
-- Знание английского языка на уровне чтения технической документации
-- Участие в разработке Open Source проектов
-- Наличие профиля на GitHub, Stack Overflow
-- Наличие проектов которые можете показать нам
-- Разработка с использованием TypeScript, знание современных frontend-библиотек и подходов к разработке
-
-## У нас
-
-- Полный рабочий день, гибкий обед и начало рабочего дня
-- Полностью «белая» заработная плата с возможностью увеличения в процессе работы (зависит от отдачи сотрудника)
-- Полис ДМС
-- Дружелюбная команда с юмором, готовая поддержать и помочь
-- Интересный проект и необычные задачи. Рутина тоже есть, но мы нацелены именно на продуктив
-- Возможность одновременно участвовать в разных проектах и развивать другие компетенции (TS и все модное)
-- Попробовать современные тренды и практики в разработке ПО
-- Никаких опенспейсов и кубиклов, а комфортное пространство в центре Тюмени
-- Готовы делиться опытом и знаниями, если вы готовы их получать
-
-
-
-Если вакансия вас заинтересовала, но есть недопонимания и вопросы, свяжитесь с нами - обсудим, договоримся.
-Большим плюсом будет выполнение тестового задания.
-Если у вас есть опыт работы с 1С, то эта вакансия не для вас.
-
-## Тестовое задание
-
-Решение принимается в виде PR к текущему проекту.
-
+
+
StatusHistory
+
+
+
+
+
+
+# Документация API
+Status History - **[API redoc](https://kaschenkkko.github.io/StatusHistory/)**
+
+# Описание проекта:
Есть несколько рабочих сервисов, у каждого сервиса есть состояние работает/не работает/работает нестабильно.
-Требуется написать API который:
-
-1. Получает и сохраняет данные: имя, состояние, описание
-2. Выводит список сервисов с актуальным состоянием
-3. По имени сервиса выдает историю изменения состояния и все данные по каждому состоянию
-
-Дополнительным плюсом будет
-
-1. По указанному интервалу выдается информация о том сколько не работал сервис и считать SLA в процентах до 3-й запятой
-
-Вывод всех данных должен быть в формате JSON
+Написан API который:
+
+1. Получает и сохраняет данные сервиса: имя, состояние, описание.
+2. Выводит список сервисов с актуальным состоянием.
+3. По id сервиса выдает историю изменения состояния и все данные по каждому состоянию.
+4. По указанному интервалу выдаёт информация о том сколько не работал сервис и считает SLA в процентах до 3-й запятой.
+
+# Описание API:
+- **GET /api/services/** - выводит список сервисов с актуальным состоянием.
+- **POST /api/services/** - добавление нового сервиса.
+- **GET /api/history/{service_id}/** - выдает историю изменений состояния.
+- **GET /api/{service_id}/{start_date}/{end_date}/**: выдаёт информация о том сколько не работал сервис и считает SLA.
+
+# Запуск проекта:
+- Клонируйте репозиторий и перейдите в него.
+- Перейдите в папку **infra** и проверьте, что файл .env заполнен данными представленными ниже:
+ ```
+ DEBUG=True
+ ALLOWED_HOSTS=127.0.0.1
+ ```
+- Из папки **infra** запустите docker-compose
+ ```
+ ~$ docker-compose up -d --build
+ ```
+- В контейнере web выполните миграции, создайте суперпользователя и соберите статику.
+ ```
+ ~$ docker-compose exec web python manage.py migrate
+ ~$ docker-compose exec web python manage.py createsuperuser
+ ~$ docker-compose exec web python manage.py collectstatic --no-input
+ ```
+
+После этого API проекта будет доступно по url-адресу [127.0.0.1/api/](http://127.0.0.1/api/)
+
+Документация к API будет доступна по url-адресу [127.0.0.1/api/docs](http://127.0.0.1/api/docs/)
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..52ed48a
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,19 @@
+
+
+
+ Documentation API
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/openapi-schema.yml b/docs/openapi-schema.yml
new file mode 100644
index 0000000..2eab914
--- /dev/null
+++ b/docs/openapi-schema.yml
@@ -0,0 +1,195 @@
+openapi: 3.0.2
+info:
+ title: 'StatusHistory'
+ version: ''
+paths:
+ /api/services/:
+ get:
+ description: 'Список сервисов.'
+ parameters: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Services'
+ description: ''
+ tags:
+ - Сервисы
+ post:
+ description: 'Создание сервиса.'
+ parameters:
+ - name: name
+ required: true
+ in: query
+ description: 'Название сервиса'
+ example: 'Крутой сервис'
+ schema:
+ type: string
+ - name: description
+ required: true
+ in: query
+ description: 'Описание сервиса'
+ example: 'Описание крутого сервиса'
+ schema:
+ type: string
+ - name: status
+ required: false
+ in: query
+ description: 'Статус работы сервиса'
+ example: 'работает'
+ schema:
+ default: 'работает'
+ type: string
+ enum: ['работает', 'не работает', 'нестабильно']
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Service"
+ description: ''
+ tags:
+ - Сервисы
+ /api/history/{service_id}/:
+ get:
+ description: 'История изменений состояния работы сервиса.'
+ parameters:
+ - name: service_id
+ required: true
+ in: path
+ description: 'ID сервиса'
+ example: 3
+ schema:
+ type: integer
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/History'
+ description: ''
+ tags:
+ - История работы сервиса
+ /api/{service_id}/{start_date}/{end_date}/:
+ get:
+ operationId: Расчёт SLA
+ description: 'Информация о том сколько не работал сервис и расчёт SLA.'
+ parameters:
+ - name: service_id
+ required: true
+ in: path
+ description: 'ID сервиса'
+ example: 4
+ schema:
+ type: integer
+ - name: start_date
+ required: true
+ in: path
+ description: 'Начало периода'
+ example: '2023-10-29'
+ schema:
+ type: string
+ - name: end_date
+ required: true
+ in: path
+ description: 'Конец периода'
+ example: '2023-11-13'
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ Информация для сервиса:
+ type: string
+ description: 'Название сервиса'
+ example: 'Fourth'
+ Service level agreement(в процентах):
+ type: integer
+ description: 'SLA'
+ example: '96.913'
+ Общее время недоступности сервиса:
+ type: string
+ description: 'Общее время недоступности'
+ example: '0 месяцев, 1 дней, 10 часов, 12 минут, 31 секунд'
+ description: ''
+ tags:
+ - Service level agreement
+
+components:
+ schemas:
+ Services:
+ type: object
+ properties:
+ id:
+ type: integer
+ readOnly: true
+ example: '1'
+ name:
+ type: string
+ maxLength: 100
+ description: 'Название'
+ example: 'СAPI2'
+ status:
+ type: string
+ maxLength: 50
+ description: 'Состояние'
+ example: 'работает'
+ description:
+ type: string
+ description: 'Описание'
+ example: 'Крутой сервис'
+ Service:
+ type: object
+ properties:
+ id:
+ title: ID
+ type: integer
+ readOnly: true
+ example: 2
+ name:
+ title: Название сервиса
+ type: string
+ example: "Крутой сервис"
+ maxLength: 100
+ status:
+ title: Статус работы сервиса
+ type: string
+ example: "работает"
+ description:
+ title: Опсиание сервиса
+ type: string
+ example: "Описание крутого сервиса"
+ History:
+ type: object
+ properties:
+ id:
+ title: ID
+ type: integer
+ readOnly: true
+ example: 1
+ name:
+ title: Название сервиса
+ type: string
+ example: "CAPI2"
+ maxLength: 100
+ service_id:
+ title: ID сервиса
+ type: string
+ example: 3
+ status:
+ title: Статус работы сервиса
+ type: string
+ example: "работает"
+ last_modified:
+ title: Дата последнего изменения работы сервиса
+ type: string
+ example: "12.11.2023 12:05:52"
\ No newline at end of file
diff --git a/infra/.env b/infra/.env
new file mode 100644
index 0000000..8b3f4b2
--- /dev/null
+++ b/infra/.env
@@ -0,0 +1,2 @@
+DEBUG=True
+ALLOWED_HOSTS=127.0.0.1
\ No newline at end of file
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
new file mode 100644
index 0000000..c395ee1
--- /dev/null
+++ b/infra/docker-compose.yml
@@ -0,0 +1,24 @@
+version: '3.8'
+
+services:
+ web:
+ build: ../services/
+ restart: always
+ volumes:
+ - static_value:/app/static/
+ env_file:
+ - .env
+
+ nginx:
+ image: nginx:1.21.3-alpine
+ ports:
+ - "80:80"
+ volumes:
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf
+ - ../services/api/docs/:/usr/share/nginx/html/api/docs/
+ - static_value:/var/html/static/
+ depends_on:
+ - web
+
+volumes:
+ static_value:
\ No newline at end of file
diff --git a/infra/nginx.conf b/infra/nginx.conf
new file mode 100644
index 0000000..a03f3d2
--- /dev/null
+++ b/infra/nginx.conf
@@ -0,0 +1,25 @@
+server {
+ listen 80;
+ server_name 127.0.0.1;
+ server_tokens off;
+
+ location /static/ {
+ autoindex on;
+ root /var/html/;
+ }
+
+ location /api/ {
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://web:8000/api/;
+ }
+
+ location /admin/ {
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Server $host;
+ proxy_pass http://web:8000/admin/;
+ }
+}
\ No newline at end of file
diff --git a/services/Dockerfile b/services/Dockerfile
new file mode 100644
index 0000000..4aaa537
--- /dev/null
+++ b/services/Dockerfile
@@ -0,0 +1,9 @@
+FROM python:3.7-slim
+
+WORKDIR /app
+
+COPY . .
+
+RUN apt-get update && pip install --upgrade pip && pip install -r /app/requirements.txt --no-cache-dir
+
+CMD ["gunicorn", "config.wsgi:application", "--bind", "0:8000"]
\ No newline at end of file
diff --git a/services/api/__init__.py b/services/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/api/admin.py b/services/api/admin.py
new file mode 100644
index 0000000..f75921c
--- /dev/null
+++ b/services/api/admin.py
@@ -0,0 +1,21 @@
+from django.contrib import admin
+
+from .models import Service, StatusHistory
+
+
+@admin.register(Service)
+class ServiceAdmin(admin.ModelAdmin):
+ list_display = ('id', 'name', 'status', 'description')
+ search_fields = ('name', 'description')
+ list_filter = ('status',)
+
+
+@admin.register(StatusHistory)
+class StatusHistorysAdmin(admin.ModelAdmin):
+ list_display = ('id', 'service', 'status', 'date')
+ search_fields = ('service__name',)
+ list_filter = ('status',)
+
+ def date(self, obj):
+ return obj.last_modified.strftime('%d.%m.%Y %H:%M:%S')
+ date.short_description = 'Дата изменения'
diff --git a/services/api/apps.py b/services/api/apps.py
new file mode 100644
index 0000000..9b148ce
--- /dev/null
+++ b/services/api/apps.py
@@ -0,0 +1,42 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'api'
+
+ def ready(self):
+ from django.db.models.signals import post_save, pre_save
+ from django.dispatch import receiver
+ from django.utils import timezone
+
+ from .models import Service, StatusHistory
+
+ @receiver(post_save, sender=Service)
+ def model_service_changed(sender, instance, **kwargs):
+ """
+ При изменении статуса работы сервиса будет создаваться новый
+ объект StatusHistory.
+ """
+
+ related_instance = StatusHistory.objects.create(service=instance)
+ related_instance.status = related_instance.service.status
+ related_instance.save()
+
+ @receiver(pre_save, sender=StatusHistory)
+ def update_start_end_time(sender, instance, **kwargs):
+ """
+ Установка start_time и end_time в соответствии с
+ изменениями статуса сервиса.
+ """
+
+ if instance.status == 'не работает' and not instance.start_time:
+ instance.start_time = timezone.now()
+
+ elif (instance.status == 'работает' or
+ instance.status == 'нестабильно'):
+ last_down_status = StatusHistory.objects.filter(
+ status='не работает', service=instance.service_id).first()
+ if last_down_status and not last_down_status.end_time:
+ last_down_status.end_time = timezone.now()
+ last_down_status.save()
diff --git a/services/api/docs/openapi-schema.yml b/services/api/docs/openapi-schema.yml
new file mode 100644
index 0000000..2eab914
--- /dev/null
+++ b/services/api/docs/openapi-schema.yml
@@ -0,0 +1,195 @@
+openapi: 3.0.2
+info:
+ title: 'StatusHistory'
+ version: ''
+paths:
+ /api/services/:
+ get:
+ description: 'Список сервисов.'
+ parameters: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Services'
+ description: ''
+ tags:
+ - Сервисы
+ post:
+ description: 'Создание сервиса.'
+ parameters:
+ - name: name
+ required: true
+ in: query
+ description: 'Название сервиса'
+ example: 'Крутой сервис'
+ schema:
+ type: string
+ - name: description
+ required: true
+ in: query
+ description: 'Описание сервиса'
+ example: 'Описание крутого сервиса'
+ schema:
+ type: string
+ - name: status
+ required: false
+ in: query
+ description: 'Статус работы сервиса'
+ example: 'работает'
+ schema:
+ default: 'работает'
+ type: string
+ enum: ['работает', 'не работает', 'нестабильно']
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Service"
+ description: ''
+ tags:
+ - Сервисы
+ /api/history/{service_id}/:
+ get:
+ description: 'История изменений состояния работы сервиса.'
+ parameters:
+ - name: service_id
+ required: true
+ in: path
+ description: 'ID сервиса'
+ example: 3
+ schema:
+ type: integer
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/History'
+ description: ''
+ tags:
+ - История работы сервиса
+ /api/{service_id}/{start_date}/{end_date}/:
+ get:
+ operationId: Расчёт SLA
+ description: 'Информация о том сколько не работал сервис и расчёт SLA.'
+ parameters:
+ - name: service_id
+ required: true
+ in: path
+ description: 'ID сервиса'
+ example: 4
+ schema:
+ type: integer
+ - name: start_date
+ required: true
+ in: path
+ description: 'Начало периода'
+ example: '2023-10-29'
+ schema:
+ type: string
+ - name: end_date
+ required: true
+ in: path
+ description: 'Конец периода'
+ example: '2023-11-13'
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ Информация для сервиса:
+ type: string
+ description: 'Название сервиса'
+ example: 'Fourth'
+ Service level agreement(в процентах):
+ type: integer
+ description: 'SLA'
+ example: '96.913'
+ Общее время недоступности сервиса:
+ type: string
+ description: 'Общее время недоступности'
+ example: '0 месяцев, 1 дней, 10 часов, 12 минут, 31 секунд'
+ description: ''
+ tags:
+ - Service level agreement
+
+components:
+ schemas:
+ Services:
+ type: object
+ properties:
+ id:
+ type: integer
+ readOnly: true
+ example: '1'
+ name:
+ type: string
+ maxLength: 100
+ description: 'Название'
+ example: 'СAPI2'
+ status:
+ type: string
+ maxLength: 50
+ description: 'Состояние'
+ example: 'работает'
+ description:
+ type: string
+ description: 'Описание'
+ example: 'Крутой сервис'
+ Service:
+ type: object
+ properties:
+ id:
+ title: ID
+ type: integer
+ readOnly: true
+ example: 2
+ name:
+ title: Название сервиса
+ type: string
+ example: "Крутой сервис"
+ maxLength: 100
+ status:
+ title: Статус работы сервиса
+ type: string
+ example: "работает"
+ description:
+ title: Опсиание сервиса
+ type: string
+ example: "Описание крутого сервиса"
+ History:
+ type: object
+ properties:
+ id:
+ title: ID
+ type: integer
+ readOnly: true
+ example: 1
+ name:
+ title: Название сервиса
+ type: string
+ example: "CAPI2"
+ maxLength: 100
+ service_id:
+ title: ID сервиса
+ type: string
+ example: 3
+ status:
+ title: Статус работы сервиса
+ type: string
+ example: "работает"
+ last_modified:
+ title: Дата последнего изменения работы сервиса
+ type: string
+ example: "12.11.2023 12:05:52"
\ No newline at end of file
diff --git a/services/api/docs/swagger.html b/services/api/docs/swagger.html
new file mode 100644
index 0000000..c340d13
--- /dev/null
+++ b/services/api/docs/swagger.html
@@ -0,0 +1,24 @@
+{% load static %}
+
+
+ Documentation API
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/services/api/migrations/0001_initial.py b/services/api/migrations/0001_initial.py
new file mode 100644
index 0000000..5171d72
--- /dev/null
+++ b/services/api/migrations/0001_initial.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.2.23 on 2023-11-12 08:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Service',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, unique=True, verbose_name='Название')),
+ ('status', models.CharField(choices=[('работает', 'работает'), ('не работает', 'не работает'), ('нестабильно', 'нестабильно')], default='работает', max_length=50, verbose_name='Состояние')),
+ ('description', models.TextField(verbose_name='Описание')),
+ ],
+ options={
+ 'ordering': ('-id',),
+ },
+ ),
+ migrations.CreateModel(
+ name='StatusHistory',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.CharField(max_length=50, verbose_name='Состояние')),
+ ('last_modified', models.DateTimeField(auto_now_add=True, verbose_name='Дата изменения')),
+ ('start_time', models.DateTimeField(blank=True, null=True, verbose_name='Время начала недоступности сервиса')),
+ ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='Время окончания недоступности сервиса')),
+ ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history', to='api.service', verbose_name='Название')),
+ ],
+ options={
+ 'ordering': ('-id',),
+ },
+ ),
+ ]
diff --git a/services/api/migrations/__init__.py b/services/api/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/api/models.py b/services/api/models.py
new file mode 100644
index 0000000..59daf00
--- /dev/null
+++ b/services/api/models.py
@@ -0,0 +1,68 @@
+from django.db import models
+from django.utils import timezone
+
+tz = timezone.get_default_timezone()
+
+
+class Service(models.Model):
+ """Модель для сервисов."""
+
+ IS_STABLE = 'работает'
+ NOT_WORKING = 'не работает'
+ INSTABILITY = 'нестабильно'
+ STATUSES = [
+ (IS_STABLE, 'работает'),
+ (NOT_WORKING, 'не работает'),
+ ('нестабильно', 'нестабильно')
+ ]
+ name = models.CharField(
+ max_length=100, unique=True,
+ verbose_name='Название'
+ )
+ status = models.CharField(
+ max_length=50, verbose_name='Состояние',
+ choices=STATUSES, default='работает'
+ )
+ description = models.TextField(verbose_name='Описание')
+
+ class Meta:
+ ordering = ('-id',)
+
+ def __str__(self):
+ return self.name
+
+
+class StatusHistory(models.Model):
+ """Модель для хранения истории изменений состояния работы сервиса."""
+
+ status = models.CharField(max_length=50, verbose_name='Состояние')
+ service = models.ForeignKey(
+ Service, related_name='history',
+ verbose_name='Название', on_delete=models.CASCADE
+ )
+ last_modified = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name='Дата изменения'
+ )
+ start_time = models.DateTimeField(
+ null=True, blank=True,
+ verbose_name='Время начала недоступности сервиса'
+ )
+ end_time = models.DateTimeField(
+ null=True, blank=True,
+ verbose_name='Время окончания недоступности сервиса'
+ )
+
+ @property
+ def downtime_duration(self):
+ if self.start_time and self.end_time:
+ return self.end_time - self.start_time
+ elif not self.end_time and self.start_time:
+ return timezone.now() - self.start_time
+ return None
+
+ class Meta:
+ ordering = ('-id',)
+
+ def __str__(self):
+ return f'Изменение сервиса "{self.service}"'
diff --git a/services/api/scripts.py b/services/api/scripts.py
new file mode 100644
index 0000000..20a5f38
--- /dev/null
+++ b/services/api/scripts.py
@@ -0,0 +1,25 @@
+def seconds_to_time(seconds: str) -> str:
+ """Перевод секунд в месяцы/дни/часы/секунды."""
+
+ seconds_in_minute = 60
+ seconds_in_hour = 3600
+ seconds_in_day = 86400
+ seconds_in_month = 2629746 # среднее количество
+ seconds = int(seconds)
+
+ months = seconds // seconds_in_month
+ seconds -= months * seconds_in_month
+
+ days = seconds // seconds_in_day
+ seconds -= days * seconds_in_day
+
+ hours = seconds // seconds_in_hour
+ seconds -= hours * seconds_in_hour
+
+ minutes = seconds // seconds_in_minute
+ seconds -= minutes * seconds_in_minute
+
+ result = (
+ f'{months} месяцев, {hours} часов, {days} дней '
+ f'{minutes} минут, {seconds} секунд')
+ return result
diff --git a/services/api/serializers.py b/services/api/serializers.py
new file mode 100644
index 0000000..e00c059
--- /dev/null
+++ b/services/api/serializers.py
@@ -0,0 +1,19 @@
+from rest_framework import serializers
+
+from .models import Service, StatusHistory
+
+
+class ServiceSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = Service
+ fields = ('id', 'name', 'status', 'description')
+
+
+class StatusHistorySerializer(serializers.ModelSerializer):
+ name = serializers.CharField(source='service.name')
+ service_id = serializers.CharField(source='service.id')
+
+ class Meta:
+ model = StatusHistory
+ fields = ('id', 'name', 'service_id', 'status', 'last_modified')
diff --git a/services/api/urls.py b/services/api/urls.py
new file mode 100644
index 0000000..80a3106
--- /dev/null
+++ b/services/api/urls.py
@@ -0,0 +1,22 @@
+from api.views import ServiceViewSet, StatusHistoryViewSet, sla_calculation
+from django.urls import include, path
+from django.views.generic import TemplateView
+from rest_framework.routers import DefaultRouter
+
+app_name = 'api'
+
+router = DefaultRouter()
+router.register('services', ServiceViewSet)
+router.register(r'history/(?P.+)',
+ StatusHistoryViewSet, basename='statushistory')
+
+
+urlpatterns = [
+ path('', include(router.urls)),
+ path('///',
+ sla_calculation),
+ path('docs/', TemplateView.as_view(
+ template_name='swagger.html',
+ extra_context={'schema_url': 'openapi-schema'}
+ ), name='swagger-ui'),
+]
diff --git a/services/api/views.py b/services/api/views.py
new file mode 100644
index 0000000..dbe21da
--- /dev/null
+++ b/services/api/views.py
@@ -0,0 +1,65 @@
+from datetime import datetime, timedelta
+
+from rest_framework import decorators, mixins, status, viewsets
+from rest_framework.permissions import AllowAny
+from rest_framework.response import Response
+
+from .models import Service, StatusHistory
+from .scripts import seconds_to_time
+from .serializers import ServiceSerializer, StatusHistorySerializer
+
+
+class ServiceViewSet(mixins.ListModelMixin, mixins.CreateModelMixin,
+ viewsets.GenericViewSet):
+ """Вьюсет для создания и получения информации о сервисах."""
+
+ permission_classes = [AllowAny]
+ queryset = Service.objects.all()
+ serializer_class = ServiceSerializer
+
+ def perform_create(self, serializer):
+ serializer.save()
+
+
+class StatusHistoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
+ """Вьюсет для получения истории изменений состояния по id сервиса."""
+
+ serializer_class = StatusHistorySerializer
+
+ def get_queryset(self):
+ service_id = self.kwargs.get('service_id')
+ return StatusHistory.objects.filter(service__id=service_id)
+
+
+@decorators.api_view(['GET'])
+def sla_calculation(request, service_id, start_date, end_date):
+ """
+ Вьюсет для подсчета общего времени недоступности сервиса и расчёта
+ SLA за заданный интервал времени.
+ """
+
+ service_downtime = StatusHistory.objects.filter(
+ service_id=service_id,
+ status='не работает',
+ last_modified__range=[start_date, end_date]
+ )
+
+ total_downtime = timedelta()
+ for downtime in service_downtime:
+ total_downtime += downtime.downtime_duration
+
+ end_date = datetime.strptime(end_date, '%Y-%m-%d')
+ start_date = datetime.strptime(start_date, '%Y-%m-%d')
+ total_time = end_date - start_date
+ availability = (total_time - total_downtime) / total_time
+ sla = availability * 100
+ total = seconds_to_time(total_downtime.seconds)
+
+ return Response(
+ {
+ 'Информация для сервиса': Service.objects.get(id=service_id).name,
+ 'Service level agreement(в процентах)': round(sla, 3),
+ 'Общее время недоступности сервиса': total
+ },
+ status=status.HTTP_200_OK
+ )
diff --git a/services/config/__init__.py b/services/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/config/asgi.py b/services/config/asgi.py
new file mode 100644
index 0000000..a00b3b5
--- /dev/null
+++ b/services/config/asgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
+
+application = get_asgi_application()
diff --git a/services/config/settings.py b/services/config/settings.py
new file mode 100644
index 0000000..dd34875
--- /dev/null
+++ b/services/config/settings.py
@@ -0,0 +1,101 @@
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+SECRET_KEY = 'django-insecure-)$n9-ip!)gr3cn=zv7#k@_#7%+%uuwuyoo2qkbgntd299s*66_'
+
+DEBUG = os.getenv('DEBUG')
+
+ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(',')
+
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'api.apps.ApiConfig',
+ 'rest_framework'
+]
+
+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 = 'config.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [
+ os.path.join(BASE_DIR, 'api/docs'),
+ ],
+ '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 = 'config.wsgi.application'
+
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
+}
+
+
+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',
+ },
+]
+
+REST_FRAMEWORK = {
+ 'DATETIME_FORMAT': '%d.%m.%Y %H:%M:%S',
+}
+
+
+LANGUAGE_CODE = 'ru-RU'
+
+USE_I18N = True
+
+USE_L10N = True
+
+TIME_ZONE = 'Europe/Moscow'
+USE_TZ = True
+
+STATICFILES_DIRS = (
+ os.path.join(BASE_DIR, 'api/docs'),
+)
+STATIC_ROOT = os.path.join(BASE_DIR, 'static')
+STATIC_URL = '/static/'
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/services/config/urls.py b/services/config/urls.py
new file mode 100644
index 0000000..b2c5a3a
--- /dev/null
+++ b/services/config/urls.py
@@ -0,0 +1,7 @@
+from django.contrib import admin
+from django.urls import include, path
+
+urlpatterns = [
+ path('api/', include('api.urls', namespace='api')),
+ path('admin/', admin.site.urls)
+]
diff --git a/services/config/wsgi.py b/services/config/wsgi.py
new file mode 100644
index 0000000..a9f185c
--- /dev/null
+++ b/services/config/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
+
+application = get_wsgi_application()
diff --git a/services/manage.py b/services/manage.py
new file mode 100644
index 0000000..fb3d917
--- /dev/null
+++ b/services/manage.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.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()
diff --git a/services/requirements.txt b/services/requirements.txt
new file mode 100644
index 0000000..d3804a2
--- /dev/null
+++ b/services/requirements.txt
@@ -0,0 +1,12 @@
+asgiref==3.7.2
+Django==3.2.23
+djangorestframework==3.14.0
+gunicorn==20.1.0
+inflection==0.5.1
+isort==5.11.5
+packaging==23.2
+pytz==2023.3.post1
+PyYAML==6.0.1
+sqlparse==0.4.4
+typing-extensions==4.7.1
+uritemplate==4.1.1