commit 705c85177a8095edf5bc9d65b04a352e9e8a0ec5 Author: Disassembler Date: Sat Sep 9 10:41:07 2023 +0200 Initial commit diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..d058e83 --- /dev/null +++ b/api/app.py @@ -0,0 +1,244 @@ +from datetime import datetime, timedelta + +from flask import Flask, request + +from .data import DataFile, DateTimeSpan, MinuteSpan + + +DATA = DataFile("data.json") + +app = Flask(__name__) + + +def get_action_at(point_in_time, schedules, overrides): + for override in overrides: + if override.start <= point_in_time < override.end: + return override.action + for span in schedules[point_in_time.weekday()]: + span = DateTimeSpan.from_minutespan(point_in_time, span) + if span.start <= point_in_time < span.end: + return True + return False + + +def get_next_action_change(point_in_time, action, schedules, overrides): + skip_schedule = False + if not any(schedules.values()): + # No daily schedule is set + overrides = [o for o in overrides if o.action] + if not overrides: + # No allow overrides exist => denied indefinitely + return None + skip_schedule = True + elif all(o == [MinuteSpan(0, 24 * 60)] for o in schedules.values()): + # Daily schedule allowed 24/7 + overrides = [o for o in overrides if not o.action] + if not overrides: + # No deny overrides exist => allowed indefinitely + return None + skip_schedule = True + if skip_schedule: + # Schedule is either empty or allows 24/7, therefore irrelevant + # and only overrides are considered. + for override_span in overrides: + if override_span.start > point_in_time: + # Nearest override didn't start yet + return override_span.start + if override_span.start <= point_in_time < override_span.end: + # We're in override + return override_span.end + # No more overrides => allowed / denied indefinitely + return None + + while True: + new_point_in_time = None + for override_span in overrides: + if override_span.start <= point_in_time < override_span.end: + # We're in override + new_point_in_time = override_span.end + if new_point_in_time is None: + dow = point_in_time.weekday() + for span in schedules[dow]: + span = DateTimeSpan.from_minutespan(point_in_time, span) + if span.end < point_in_time: + # Skip already elapsed spans + continue + if span.start <= point_in_time < span.end: + # We're in span allowed by schedule, get the span end + new_point_in_time = span.end + # Check if any upcoming override comes sooner that the span end + for override_span in overrides: + if override_span.start >= point_in_time and override_span.start < span.end: + new_point_in_time = override_span.start + break + break + if new_point_in_time is None: + # We're in span denied by schedule, get next span start + days = 0 + while True: + for span in schedules[(dow + days) % 7]: + span = DateTimeSpan.from_minutespan(point_in_time + timedelta(days=days), span) + if span.start > point_in_time: + new_point_in_time = span.start + # Check if any upcoming override comes sooner that the span start + for override_span in overrides: + if ( + override_span.start >= point_in_time + and override_span.start < span.start + ): + new_point_in_time = override_span.start + break + break + if new_point_in_time: + break + days += 1 + if action != get_action_at(new_point_in_time, schedules, overrides): + return new_point_in_time + point_in_time = new_point_in_time + + +def update_status(status, schedules, overrides): + now = datetime.now() + status.action = get_action_at(now, schedules, overrides) + status.until = get_next_action_change(now, status.action, schedules, overrides) + status.then_until = ( + get_next_action_change(status.until, not status.action, schedules, overrides) + if status.until + else None + ) + + +def update_schedule(schedule, span, allow): + if allow: + schedule.append(span) + schedule.sort() + i = len(schedule) - 1 + while i: + if schedule[i - 1].end >= schedule[i].start: + # spans overlap -> merge together + schedule[i - 1].start = min(schedule[i - 1].start, schedule[i].start) + schedule[i - 1].end = max(schedule[i - 1].end, schedule[i].end) + del schedule[i] + i -= 1 + else: + i = len(schedule) - 1 + while i >= 0: + if span.start <= schedule[i].start and span.end >= schedule[i].end: + # deny period larger than span -> remove span + del schedule[i] + elif span.start > schedule[i].start and span.end < schedule[i].end: + # deny period shorter than span -> split span into two + schedule.append(MinuteSpan(span.end, schedule[i].end)) + schedule[i].end = span.start + elif span.start <= schedule[i].start < span.end: + # span starts within deny period -> move start + schedule[i].start = span.end + elif span.start < schedule[i].end <= span.end: + # span ends within deny period -> move end + schedule[i].end = span.start + i -= 1 + schedule.sort() + + +def update_overrides(overrides, span): + now = datetime.now() + if span.end <= now: + # The span is in the past, nothing to do + return + i = len(overrides) - 1 + while i >= 0: + if span.start <= overrides[i].start and span.end >= overrides[i].end: + # new override period larger than old override -> remove old override + del overrides[i] + elif span.start > overrides[i].start and span.end < overrides[i].end: + if span.start > now: + # new override period shorter than old override -> split old override into two + overrides.append(DateTimeSpan(span.end, overrides[i].end, overrides[i].action)) + overrides[i].end = span.start + else: + # new override period would split the override, but the first part + # has already elapsed -> move old override start + overrides[i].start = span.end + elif span.start <= overrides[i].start < span.end: + # new override starts within old overrides period -> move old override start + overrides[i].start = span.end + elif span.start < overrides[i].end <= span.end: + # new override ends within old overrides period -> move old override end + overrides[i].end = span.start + i -= 1 + overrides.append(span) + overrides.sort() + i = len(overrides) - 1 + while i: + if ( + overrides[i - 1].end >= overrides[i].start + and overrides[i - 1].action == overrides[i].action + ): + # overrides of the same type overlap -> merge together + overrides[i - 1].start = min(overrides[i - 1].start, overrides[i].start) + overrides[i - 1].end = max(overrides[i - 1].end, overrides[i].end) + del overrides[i] + i -= 1 + + +@app.get("/data") +def get_data(): + with DATA as data: + if data.status.until and datetime.now() >= data.status.until: + update_status(data.status, data.schedules, data.overrides) + data.save() + return data.to_dict() + + +@app.post("/override") +def post_override(): + span = DateTimeSpan.from_args(**request.json) + with DATA as data: + update_overrides(data.overrides, span) + update_status(data.status, data.schedules, data.overrides) + data.save() + return data.to_dict() + + +@app.post("/delete-override") +def delete_override(): + span = DateTimeSpan.from_args(**request.json) + with DATA as data: + try: + data.overrides.remove(span) + update_status(data.status, data.schedules, data.overrides) + data.save() + except ValueError: + pass + return data.to_dict() + + +@app.post("/schedule") +def post_schedule(): + schedule = request.json + span = MinuteSpan.from_args(**schedule) + with DATA as data: + for day in schedule["days"]: + update_schedule(data.schedules[day], span, schedule["action"]) + update_status(data.status, data.schedules, data.overrides) + data.save() + return data.to_dict() + + +@app.get("/fast-action/") +def fast_action(minutes): + with DATA as data: + now = datetime.now().replace(second=0, microsecond=0) + if minutes: + start = now + action = get_action_at(now, data.schedules, data.overrides) + if action: + start = get_next_action_change(now, action, data.schedules, data.overrides) + end = start + timedelta(minutes=minutes) + span = DateTimeSpan(start, end, True) + else: + span = DateTimeSpan(now, now + timedelta(minutes=5), False) + update_overrides(data.overrides, span) + update_status(data.status, data.schedules, data.overrides) + data.save() + return data.to_dict() diff --git a/api/data.py b/api/data.py new file mode 100644 index 0000000..e82dbb6 --- /dev/null +++ b/api/data.py @@ -0,0 +1,144 @@ +import fcntl +import json +from datetime import datetime, timedelta +from pathlib import Path + + +MINS = "minutes" + + +class Span: + def __init__(self, start, end): + self.start = min(start, end) + self.end = max(start, end) + + def __eq__(self, other): + return (self.start, self.end) == (other.start, other.end) + + def __lt__(self, other): + return (self.start, self.end) < (other.start, other.end) + + +class MinuteSpan(Span): + @classmethod + def from_args(cls, **kwargs): + start = kwargs["start"].split(":") + start = int(start[0]) * 60 + int(start[1]) + end = kwargs["end"].split(":") + end = int(end[0]) * 60 + int(end[1]) + if end == 0: + # To 00:00 should mean until midnight (24:00) + end = 24 * 60 + return cls(start, end) + + def to_dict(self): + return { + "start": f"{self.start // 60}:{self.start % 60:02}", + "end": f"{self.end // 60}:{self.end % 60:02}", + } + + +class DateTimeSpan(Span): + def __init__(self, start, end, action): + super().__init__(start, end) + self.action = action + + def __eq__(self, other): + return (self.start, self.end, self.action) == (other.start, other.end, other.action) + + @classmethod + def from_args(cls, **kwargs): + return cls( + datetime.fromisoformat(kwargs["start"]), + datetime.fromisoformat(kwargs["end"]), + kwargs["action"], + ) + + @classmethod + def from_minutespan(cls, date, minutespan): + start = minutespan.start + end = minutespan.end + add_days = 0 + if end == 24 * 60: + # Ending midnight needs to be set as 0:00 of the next day + end = 0 + add_days = 1 + return cls( + date.replace(hour=start // 60, minute=start % 60, second=0, microsecond=0), + date.replace(hour=end // 60, minute=end % 60, second=0, microsecond=0) + + timedelta(days=add_days), + True, + ) + + def to_dict(self): + return { + "start": self.start.isoformat(timespec=MINS), + "end": self.end.isoformat(timespec=MINS), + "action": self.action, + } + + +class Status: + def __init__(self, action, until=None, then_until=None): + self.action = action + self.until = until + self.then_until = then_until + + @classmethod + def from_args(cls, **kwargs): + until = kwargs.get("until") + then_until = kwargs.get("then_until") + return cls( + kwargs["action"], + datetime.fromisoformat(until) if until else None, + datetime.fromisoformat(then_until) if then_until else None, + ) + + def to_dict(self): + return { + "action": self.action, + "until": self.until.isoformat(timespec=MINS) if self.until else None, + "then_until": self.then_until.isoformat(timespec=MINS) if self.then_until else None, + } + + +class DataFile: + def __init__(self, path): + self._path = Path(path) + self._file = None + self.status = None + self.schedules = None + self.overrides = None + + def __enter__(self): + self._file = self._path.open("r+", encoding="utf-8") + fcntl.flock(self._file, fcntl.LOCK_EX) + data = json.load(self._file) + self.status = Status.from_args(**data.get("status", {"action": False})) + self.schedules = { + int(day): [MinuteSpan.from_args(**span) for span in schedule] + for day, schedule in data.get("schedules", {day: [] for day in range(7)}).items() + } + overrides = [DateTimeSpan.from_args(**override) for override in data.get("overrides", [])] + # Remove expired overrides + now = datetime.now() + self.overrides = [o for o in overrides if o.end > now] + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._file.close() + + def to_dict(self): + return { + "status": self.status.to_dict(), + "schedules": { + day: [span.to_dict() for span in schedule] + for day, schedule in self.schedules.items() + }, + "overrides": [override.to_dict() for override in self.overrides], + } + + def save(self): + self._file.seek(0) + self._file.truncate(0) + json.dump(self.to_dict(), self._file) diff --git a/client/Installer/Installer.wixproj b/client/Installer/Installer.wixproj new file mode 100644 index 0000000..cca8ec4 --- /dev/null +++ b/client/Installer/Installer.wixproj @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/Installer/Package.wxs b/client/Installer/Package.wxs new file mode 100644 index 0000000..ec84a6e --- /dev/null +++ b/client/Installer/Package.wxs @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/ParentalControlService.sln b/client/ParentalControlService.sln new file mode 100644 index 0000000..279fd10 --- /dev/null +++ b/client/ParentalControlService.sln @@ -0,0 +1,61 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34024.191 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ParentalControlService", "ParentalControlService\ParentalControlService.csproj", "{050D342F-0A53-4A08-B56A-AA63A22A74A9}" +EndProject +Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Installer", "Installer\Installer.wixproj", "{C4838DE1-3AC8-4931-80C2-340EBA94996F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|ARM64 = Debug|ARM64 + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|ARM64 = Release|ARM64 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|ARM64.Build.0 = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|x64.Build.0 = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Debug|x86.Build.0 = Debug|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|Any CPU.Build.0 = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|ARM64.ActiveCfg = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|ARM64.Build.0 = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|x64.ActiveCfg = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|x64.Build.0 = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|x86.ActiveCfg = Release|Any CPU + {050D342F-0A53-4A08-B56A-AA63A22A74A9}.Release|x86.Build.0 = Release|Any CPU + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|Any CPU.ActiveCfg = Debug|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|Any CPU.Build.0 = Debug|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|ARM64.Build.0 = Debug|ARM64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|x64.ActiveCfg = Debug|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|x64.Build.0 = Debug|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|x86.ActiveCfg = Debug|x86 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Debug|x86.Build.0 = Debug|x86 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|Any CPU.ActiveCfg = Release|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|Any CPU.Build.0 = Release|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|ARM64.ActiveCfg = Release|ARM64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|ARM64.Build.0 = Release|ARM64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|x64.ActiveCfg = Release|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|x64.Build.0 = Release|x64 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|x86.ActiveCfg = Release|x86 + {C4838DE1-3AC8-4931-80C2-340EBA94996F}.Release|x86.Build.0 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E4EF7269-DAC7-4C33-9AF1-FCAEBAE0AD77} + EndGlobalSection +EndGlobal diff --git a/client/ParentalControlService/Data.cs b/client/ParentalControlService/Data.cs new file mode 100644 index 0000000..77f70cb --- /dev/null +++ b/client/ParentalControlService/Data.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ParentalControlService { + public struct Status + { + [JsonPropertyName("action")] + public bool Action { get; set; } + [JsonPropertyName("until")] + public DateTime? Until { get; set; } + [JsonPropertyName("then_until")] + public DateTime? ThenUntil { get; set; } + + public override bool Equals(object? obj) { + return obj is Status other && Equals(other); + } + public readonly bool Equals(Status other) { + return Action == other.Action && Until == other.Until && ThenUntil == other.ThenUntil; + } + public override readonly int GetHashCode() { + return HashCode.Combine(Action, Until, ThenUntil); + } + public static bool operator ==(Status lhs, Status rhs) { + return lhs.Action == rhs.Action && lhs.Until == rhs.Until && lhs.ThenUntil == rhs.ThenUntil; + } + public static bool operator !=(Status lhs, Status rhs) { + return !(lhs == rhs); + } + } + + public struct Schedule + { + [JsonPropertyName("start")] + public string? Start { get; set; } + [JsonPropertyName("end")] + public string? End { get; set; } + } + + public struct Override + { + [JsonPropertyName("start")] + public DateTime Start { get; set; } + [JsonPropertyName("end")] + public DateTime End { get; set; } + [JsonPropertyName("action")] + public bool Action { get; set; } + } + + public struct Data + { + [JsonPropertyName("status")] + public Status Status { get; set; } + [JsonPropertyName("schedules")] + public Dictionary> Schedules { get; set; } + [JsonPropertyName("overrides")] + public List Overrides { get; set; } + } + + public struct Config + { + [JsonPropertyName("url")] + public string Url { get; set; } + [JsonPropertyName("username")] + public string Username { get; set; } + [JsonPropertyName("password")] + public string Password { get; set; } + } +} diff --git a/client/ParentalControlService/ParentalControlService.csproj b/client/ParentalControlService/ParentalControlService.csproj new file mode 100644 index 0000000..f903121 --- /dev/null +++ b/client/ParentalControlService/ParentalControlService.csproj @@ -0,0 +1,18 @@ + + + + net7.0-windows + enable + disable + dotnet-ParentalControlService-c499de7d-03ae-4d69-a7cf-e634d0dce29d + exe + true + win-x64 + x64 + 1.1.0 + + + + + + diff --git a/client/ParentalControlService/ParentalControlService.csproj.user b/client/ParentalControlService/ParentalControlService.csproj.user new file mode 100644 index 0000000..df92f87 --- /dev/null +++ b/client/ParentalControlService/ParentalControlService.csproj.user @@ -0,0 +1,6 @@ + + + + <_LastSelectedProfileId>C:\Users\Disassembler\Desktop\client\ParentalControlService\Properties\PublishProfiles\FolderProfile.pubxml + + \ No newline at end of file diff --git a/client/ParentalControlService/ParentalControlSvc.cs b/client/ParentalControlService/ParentalControlSvc.cs new file mode 100644 index 0000000..1ca27f3 --- /dev/null +++ b/client/ParentalControlService/ParentalControlSvc.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using Timer = System.Timers.Timer; + +namespace ParentalControlService; + +public class ParentalControlSvc : BackgroundService +{ + private readonly string configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json"); + private readonly string statusPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "status.json"); + private readonly ILogger _logger; + private Config config; + private Status status; + private bool currentAction; + private readonly Timer checkTimer; + private readonly HttpClient httpClient; + + public ParentalControlSvc(ILogger logger) + { + _logger = logger; + + // Load status + try { + status = JsonSerializer.Deserialize(File.ReadAllText(statusPath))!; + } + catch (FileNotFoundException) { + status = new Status() { Action = true, Until = DateTime.MaxValue }; + } + currentAction = status.Action; + + // Load config + try + { + config = JsonSerializer.Deserialize(File.ReadAllText(configPath))!; + } + catch (FileNotFoundException) + { + config = new Config() { Username = "", Password = "" }; + } + + // Create HTTP Client + httpClient = new() { BaseAddress = new Uri(config.Url), Timeout = TimeSpan.FromSeconds(5) }; + string credentials = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{config.Username}:{config.Password}")); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ParentalControl", "1.1.0")); + + // Initialize Timer + checkTimer = new(5000); + checkTimer.Elapsed += new ElapsedEventHandler(CheckTimerTick); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { + await CheckTimerTick(); + checkTimer.Start(); + while (!stoppingToken.IsCancellationRequested) { + await Task.Delay(1000, stoppingToken); + } + } + catch (TaskCanceledException) { + checkTimer.Stop(); + } + catch (Exception ex) { + _logger.LogError(ex, "{Message}", ex.Message); + Environment.Exit(1); + } + } + + private void SaveStatus() { + File.WriteAllText(statusPath, JsonSerializer.Serialize(status)); + } + + private static void RunScript(string scriptName) { + scriptName = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"{scriptName}.ps1"); + if (!File.Exists(scriptName)) { + return; + } + ProcessStartInfo psi = new() { + FileName = "powershell.exe", + Arguments = $"-ExecutionPolicy Bypass -File \"{scriptName}\"", + UseShellExecute = false, + WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory, + }; + Process.Start(psi); + } + + private async Task CheckTimerTick() { + try { + Data data = await httpClient.GetFromJsonAsync("api/data"); + if (data.Status != status) { + status = data.Status; + SaveStatus(); + } + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { + _logger.LogError(ex, "{Message}", ex.Message); + } + if (status.Until != null && DateTime.Now >= status.Until) { + // Fetch failed, we need to advance the schedule on our own + status.Action = !status.Action; + status.Until = status.ThenUntil; + status.ThenUntil = null; + SaveStatus(); + } + + if (currentAction == status.Action) { + return; + } + currentAction = !currentAction; + if (currentAction) { + _logger.LogInformation("[{time}] Unblocked", DateTime.Now); + RunScript("unblock"); + } + else { + _logger.LogInformation("[{time}] Blocked", DateTime.Now); + RunScript("block"); + } + } + + private async void CheckTimerTick(object? sender, ElapsedEventArgs e) { + await CheckTimerTick(); + } +} diff --git a/client/ParentalControlService/Program.cs b/client/ParentalControlService/Program.cs new file mode 100644 index 0000000..292cd72 --- /dev/null +++ b/client/ParentalControlService/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Configuration; +using Microsoft.Extensions.Logging.EventLog; +using Microsoft.Extensions.Logging; +using ParentalControlService; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddWindowsService(options => { + options.ServiceName = "Marek Parental Control"; +}); + +LoggerProviderOptions.RegisterProviderOptions(builder.Services); + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); + +IHost host = builder.Build(); +host.Run(); diff --git a/client/ParentalControlService/Properties/PublishProfiles/FolderProfile.pubxml b/client/ParentalControlService/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..c3cc945 --- /dev/null +++ b/client/ParentalControlService/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,18 @@ + + + + + Release + Any CPU + bin\Release\net7.0-windows\win-x64\publish\ + FileSystem + <_TargetId>Folder + net7.0-windows + win-x64 + true + true + true + + \ No newline at end of file diff --git a/client/ParentalControlService/Properties/launchSettings.json b/client/ParentalControlService/Properties/launchSettings.json new file mode 100644 index 0000000..b5c452d --- /dev/null +++ b/client/ParentalControlService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "ParentalControlService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/client/ParentalControlService/appsettings.Development.json b/client/ParentalControlService/appsettings.Development.json new file mode 100644 index 0000000..6901764 --- /dev/null +++ b/client/ParentalControlService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/client/ParentalControlService/appsettings.json b/client/ParentalControlService/appsettings.json new file mode 100644 index 0000000..6901764 --- /dev/null +++ b/client/ParentalControlService/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/client/block.ps1 b/client/block.ps1 new file mode 100644 index 0000000..d0f9b04 --- /dev/null +++ b/client/block.ps1 @@ -0,0 +1,8 @@ +Start-Sleep 1 +C:\Windows\System32\net.exe user Marek /active:no +$session_id = ((C:\Windows\System32\quser.exe | Where-Object {$_ -match 'Marek' }) -split ' +')[2] +If ($session_id) { + logoff $session_id + Start-Sleep 1 + shutdown /s /f /t 0 +} diff --git a/client/config.json b/client/config.json new file mode 100644 index 0000000..824fec5 --- /dev/null +++ b/client/config.json @@ -0,0 +1,5 @@ +{ + "url": "https://pc.dasm.cz", + "username": "parent", + "password": "DA2ptE{!my_hSN6=" +} diff --git a/client/unblock.ps1 b/client/unblock.ps1 new file mode 100644 index 0000000..5c362a8 --- /dev/null +++ b/client/unblock.ps1 @@ -0,0 +1 @@ +C:\Windows\System32\net.exe user Marek /active:yes diff --git a/data.json b/data.json new file mode 100644 index 0000000..aff2a9b --- /dev/null +++ b/data.json @@ -0,0 +1 @@ +{"schedules": {"0": [], "1": [], "2": [], "3": [], "4": [], "5": [], "6": []}, "overrides": []} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..95fb918 --- /dev/null +++ b/index.html @@ -0,0 +1,71 @@ + + + + Parental Control + + + + + + + +
+

Stav

+
 
+ + + + +
+
+

Výjimky

+
+
+ +
+ +
+ +
+ +
+
+
+

Plán

+
+ Pondělí: + Úterý: + Středa: + Čtvrtek: + Pátek: + Sobota: + Neděle: +
+
+ + + + + + + + + +
+
+ + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..75e083b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "parental-control" +version = "0.0.1" +authors = [ + {name = "Disassembler", email = "disassembler@dasm.cz"}, +] +requires-python = ">=3.6" +license = {text = "MIT"} +dependencies = [ + "flask", +] + +[tool.setuptools] +packages = ["api"] diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..640b41a --- /dev/null +++ b/static/script.js @@ -0,0 +1,122 @@ +function humanReadableDate(str) { + let date = new Date(); + let today = date.toISOString().split("T")[0]; + date.setDate(date.getDate() - 1); + let yesterday = date.toISOString().split("T")[0]; + date.setDate(date.getDate() + 2); + let tomorrow = date.toISOString().split("T")[0]; + return str.replace("T", " ").replaceAll(yesterday, "včera").replaceAll(today, "dnes").replaceAll(tomorrow, "zítra"); +} + +function refreshStatus(data) { + let status = document.getElementById("status"); + let action, then_action; + if (data["action"]) { + action = "Povoleno" + then_action = "zakázáno" + } else { + action = "Zakázáno" + then_action = "povoleno" + } + let text = action; + if (data["until"]) { + text += " do " + humanReadableDate(data["until"]) + ", pak " + then_action; + if (data["then_until"]) { + text += " do " + humanReadableDate(data["then_until"]); + } else { + text += " napořád" + } + } else { + text += " napořád" + } + status.textContent = text; +} + +function refreshOverrides(data) { + let overrides = document.getElementById("overrides"); + overrides.innerHTML = ""; + for (const record of data) { + let overrideText = document.createElement("span"); + overrideText.textContent = (record["action"] ? "Povoleno" : "Zakázáno") + " od " + humanReadableDate(record["start"]) + " do " + humanReadableDate(record["end"]); + let overrideButton = document.createElement("input"); + overrideButton.type = "button"; + overrideButton.value = "❌"; + overrideButton.addEventListener("click", async () => { + let response = await fetch("api/delete-override", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + action: record["action"], + start: record["start"], + end: record["end"] + }), + }); + await refresh(await response.json()); + }) + overrides.appendChild(overrideText); + overrides.appendChild(overrideButton); + } +} + +function refreshSchedules(data) { + for (const [day, entries] of Object.entries(data)) { + let schedule = "Zakázáno" + if (entries.length > 0) { + schedule = entries.map((entry) => entry["start"] + " - " + entry["end"]).join(", "); + } + document.getElementById("schedule-" + day).textContent = schedule; + } +} + +async function refresh(data) { + data = data || await (await fetch("api/data")).json(); + refreshStatus(data["status"]); + refreshOverrides(data["overrides"]); + refreshSchedules(data["schedules"]); +} + +async function addOverride(e) { + e.preventDefault(); + let response = await fetch("api/override", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + action: document.getElementById("override-action").value == "allow", + start: document.getElementById("override-start").value, + end: document.getElementById("override-end").value, + }), + }); + await refresh(await response.json()); + document.getElementById("add-override").reset(); +} + +async function updateSchedule(e) { + e.preventDefault(); + let response = await fetch("api/schedule", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + action: document.getElementById("schedule-action").value == "allow", + days: Array.from(document.getElementById("schedule-days").selectedOptions).map(v => parseInt(v.value)), + start: document.getElementById("schedule-start").value, + end: document.getElementById("schedule-end").value + }) + }); + await refresh(await response.json()); + document.getElementById("update-schedule").reset(); +} + +async function fastAction(e) { + let response = await fetch("api/fast-action/" + e.target.dataset.mins); + await refresh(await response.json()); +} + +window.addEventListener("DOMContentLoaded", () => { + refresh(); + document.getElementById("add-override").addEventListener("submit", addOverride); + document.getElementById("update-schedule").addEventListener("submit", updateSchedule); + Array.from(document.getElementsByClassName("fast-action")).forEach(function(element) { + element.addEventListener("click", fastAction); + }); + window.setInterval(refresh, 5000); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..0c0f739 --- /dev/null +++ b/static/style.css @@ -0,0 +1,65 @@ +body { + max-width: 500px; + font-family: serif; +} +h2 { + margin: 1em 0 0.5em; +} +form { + display: grid; + padding: 0 1em 1em 1em; + background: #eee; + border: 1px solid #ccc; + margin-top: 1em; +} +input, select { + background: #fff; + border: 1px solid #999; + padding: 0.5em; +} +input[type="button"] { + background: #eee; +} +input[type="button"]:hover { + background: #ddd; +} +input[type="submit"] { + margin-top: 0.5em; +} +input[type="submit"]:hover { + background: #f6f6f6; +} +label { + padding: 0.5em 0.5em 0.5em 0; +} +#status { + margin-bottom: 1em; +} +#overrides { + display: grid; + grid-template-columns: 1fr 30px; + margin-top: -0.4em; +} +#schedules { + display: grid; + grid-template-columns: 5em 1fr; + margin-top: -0.4em; +} +#overrides span, #schedules span { + margin: 0.5em 0; +} + +@media (min-width: 400px) { + form { + grid-template-columns: 50px 1fr; + grid-gap: 2px; + padding: 1em; + } + form label { + grid-column: 1 / 2; + text-align: right; + } + form input, form select { + grid-column: 2 / 3; + } +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..9b63fb6 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,389 @@ +from datetime import datetime +from unittest.mock import patch + +import pytest + +from parental_control import DATETIME_FORMAT, app +from parental_control.span import DateTimeSpan, MinuteSpan + + +@pytest.mark.parametrize( + "point_in_time, schedules, overrides, expected_result", + [ + # Point in time within override + ( + "2023-08-24 14:00", + {day: [] for day in range(7)}, + [{"start": "2023-08-24 12:00", "end": "2023-08-24 18:00", "action": True}], + True, + ), + # Point in time within schedule + ( + "2023-08-24 14:00", + {3: [{"start": "10:00", "end": "16:00"}]}, + [], + True, + ), + # Point in time outside schedule and override + ( + "2023-08-24 08:00", + {3: [{"start": "10:00", "end": "16:00"}]}, + [{"start": "2023-08-24 12:00", "end": "2023-08-24 18:00", "action": True}], + False, + ), + ], +) +def test_get_action_at(point_in_time, schedules, overrides, expected_result): + point_in_time = datetime.strptime(point_in_time, DATETIME_FORMAT) + schedules = { + day: [MinuteSpan.from_args(**span) for span in schedule] + for day, schedule in schedules.items() + } + overrides = [DateTimeSpan.from_args(**o) for o in overrides] + + result = app.get_action_at(point_in_time, schedules, overrides) + + assert result == expected_result + + +@pytest.mark.parametrize( + "point_in_time, action, schedules, overrides, expected_result", + [ + # No schedule, no overrides, next change will never occur + ( + "2023-08-24 08:00", + False, + {day: [] for day in range(7)}, + [], + None, + ), + # Schedule 24/7, no overrides, next change will never occur + ( + "2023-08-24 08:00", + True, + {day: [{"start": "00:00", "end": "24:00"}] for day in range(7)}, + [], + None, + ), + # No schedule, next change occurs after allow override starts + ( + "2023-08-24 08:00", + False, + {day: [] for day in range(7)}, + [{"start": "2023-08-26 10:00", "end": "2023-08-26 14:00", "action": True}], + "2023-08-26 10:00", + ), + # Schedule 24/7, next change occurs after deny override starts + ( + "2023-08-24 08:00", + True, + {day: [{"start": "00:00", "end": "24:00"}] for day in range(7)}, + [{"start": "2023-08-26 10:00", "end": "2023-08-26 14:00", "action": False}], + "2023-08-26 10:00", + ), + # No schedule, expired overrides, next change will never occur + ( + "2023-08-24 08:00", + False, + {day: [] for day in range(7)}, + [{"start": "2023-08-23 10:00", "end": "2023-08-23 14:00", "action": True}], + None, + ), + # No schedule, next change occurs after allow override expires + ( + "2023-08-24 08:00", + True, + {day: [] for day in range(7)}, + [{"start": "2023-08-24 08:00", "end": "2023-08-26 14:00", "action": True}], + "2023-08-26 14:00", + ), + # Next change occurs after allow override expires + ( + "2023-08-24 08:00", + True, + {day: [{"start": "14:00", "end": "16:00"}] for day in range(7)}, + [{"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}], + "2023-08-24 10:00", + ), + # Next change occurs after schedule span ends + ( + "2023-08-24 14:00", + True, + { + day: [{"start": "08:00", "end": "10:00"}, {"start": "14:00", "end": "16:00"}] + for day in range(7) + }, + [{"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}], + "2023-08-24 16:00", + ), + # Next change occurs after schedule span starts + ( + "2023-08-24 13:00", + False, + { + day: [{"start": "08:00", "end": "10:00"}, {"start": "14:00", "end": "16:00"}] + for day in range(7) + }, + [{"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}], + "2023-08-24 14:00", + ), + # Next change occurs after deny override starts + ( + "2023-08-24 11:00", + True, + {day: [{"start": "10:00", "end": "16:00"}] for day in range(7)}, + [{"start": "2023-08-24 12:00", "end": "2023-08-24 14:00", "action": False}], + "2023-08-24 12:00", + ), + # Next change occurs after allow override starts + ( + "2023-08-24 10:00", + False, + {day: [{"start": "16:00", "end": "18:00"}] for day in range(7)}, + [{"start": "2023-08-24 12:00", "end": "2023-08-24 14:00", "action": True}], + "2023-08-24 12:00", + ), + # Next change occurs after schedule span ends after 7 days + ( + "2023-08-21 13:00", + True, + { + 0: [{"start": "00:00", "end": "04:00"}, {"start": "10:00", "end": "24:00"}], + 1: [{"start": "00:00", "end": "24:00"}], + 2: [{"start": "00:00", "end": "24:00"}], + 3: [{"start": "00:00", "end": "24:00"}], + 4: [{"start": "00:00", "end": "24:00"}], + 5: [{"start": "00:00", "end": "24:00"}], + 6: [{"start": "00:00", "end": "24:00"}], + }, + [{"start": "2023-08-24 12:00", "end": "2023-08-24 14:00", "action": True}], + "2023-08-28 04:00", + ), + # Next change occurs after schedule span starts after 7 days + ( + "2023-08-21 13:00", + False, + { + 0: [{"start": "00:00", "end": "04:00"}], + 1: [], + 2: [], + 3: [], + 4: [], + 5: [], + 6: [], + }, + [{"start": "2023-08-31 12:00", "end": "2023-08-31 14:00", "action": True}], + "2023-08-28 00:00", + ), + ], +) +def test_get_next_action_change(point_in_time, action, schedules, overrides, expected_result): + point_in_time = datetime.strptime(point_in_time, DATETIME_FORMAT) + schedules = { + day: [MinuteSpan.from_args(**span) for span in schedule] + for day, schedule in schedules.items() + } + overrides = [DateTimeSpan.from_args(**o) for o in overrides] + + result = app.get_next_action_change(point_in_time, action, schedules, overrides) + + if expected_result: + expected_result = datetime.strptime(expected_result, DATETIME_FORMAT) + assert result == expected_result + + +@pytest.mark.parametrize( + "schedules, overrides, expected_result", + [ + ( + {day: [] for day in range(7)}, + [], + {"action": False, "until": "", "then_until": ""}, + ), + ( + {day: [{"start": "14:00", "end": "16:00"}] for day in range(7)}, + [{"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}], + {"action": False, "until": "2023-08-24 14:00", "then_until": "2023-08-24 16:00"}, + ), + ( + {day: [] for day in range(7)}, + [{"start": "2023-08-24 08:00", "end": "2023-08-24 12:00", "action": True}], + {"action": True, "until": "2023-08-24 12:00", "then_until": ""}, + ), + ], +) +@patch("parental_control.app.datetime", wraps=datetime) +def test_calculate_status(mock_datetime, schedules, overrides, expected_result): + mock_datetime.now.return_value = datetime(2023, 8, 24, 11) + schedules = { + day: [MinuteSpan.from_args(**span) for span in schedule] + for day, schedule in schedules.items() + } + overrides = [DateTimeSpan.from_args(**o) for o in overrides] + + result = app.calculate_status(schedules, overrides) + + assert result == expected_result + + +@pytest.mark.parametrize( + "schedule, span, action, expected_result", + [ + # Allow span in empty schedule + ( + [], + {"start": "09:00", "end": "11:00"}, + True, + [{"start": "9:00", "end": "11:00"}], + ), + # Allow span same as already existing span + ( + [{"start": "9:00", "end": "11:00"}], + {"start": "09:00", "end": "11:00"}, + True, + [{"start": "9:00", "end": "11:00"}], + ), + # Allow span (starting at 0:00) prepening it before existing span + ( + [{"start": "9:00", "end": "11:00"}], + {"start": "00:00", "end": "05:00"}, + True, + [{"start": "0:00", "end": "5:00"}, {"start": "9:00", "end": "11:00"}], + ), + # Allow span (ending at 24:00) appending it after existing span + ( + [{"start": "9:00", "end": "11:00"}], + {"start": "19:00", "end": "0:00"}, + True, + [{"start": "9:00", "end": "11:00"}, {"start": "19:00", "end": "24:00"}], + ), + # Allow span merging existing spans together + ( + [{"start": "9:00", "end": "11:00"}, {"start": "14:00", "end": "16:00"}], + {"start": "11:00", "end": "14:00"}, + True, + [{"start": "9:00", "end": "16:00"}], + ), + # Deny span from empty schedule + ( + [], + {"start": "12:00", "end": "14:00"}, + False, + [], + ), + # Deny span which is already denied + ( + [{"start": "9:00", "end": "11:00"}, {"start": "16:00", "end": "18:00"}], + {"start": "12:00", "end": "14:00"}, + False, + [{"start": "9:00", "end": "11:00"}, {"start": "16:00", "end": "18:00"}], + ), + # Deny span removing existing allow spans + ( + [{"start": "9:00", "end": "11:00"}, {"start": "16:00", "end": "18:00"}], + {"start": "0:00", "end": "0:00"}, + False, + [], + ), + # Deny span within allow span, splitting the allow span + ( + [{"start": "9:00", "end": "16:00"}], + {"start": "11:00", "end": "14:00"}, + False, + [{"start": "9:00", "end": "11:00"}, {"start": "14:00", "end": "16:00"}], + ), + # Deny span moving end and start of existing allow spans + ( + [{"start": "9:00", "end": "11:00"}, {"start": "16:00", "end": "18:00"}], + {"start": "10:00", "end": "17:00"}, + False, + [{"start": "9:00", "end": "10:00"}, {"start": "17:00", "end": "18:00"}], + ), + ], +) +def test_update_schedule(schedule, span, action, expected_result): + schedule = [MinuteSpan.from_args(**s) for s in schedule] + span = MinuteSpan.from_args(**span) + + app.update_schedule(schedule, span, action) + + expected_result = [MinuteSpan.from_args(**s) for s in expected_result] + assert schedule == expected_result + + +@pytest.mark.parametrize( + "overrides, start, end, action, expected_result", + [ + # Add override into empty list of overrides + ( + [], + {"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}, + [{"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}], + ), + # Add override into list of overrides + ( + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 06:00", "action": True}, + {"start": "2023-08-24 15:00", "end": "2023-08-24 20:00", "action": True}, + ], + {"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}, + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 06:00", "action": True}, + {"start": "2023-08-24 08:00", "end": "2023-08-24 10:00", "action": True}, + {"start": "2023-08-24 15:00", "end": "2023-08-24 20:00", "action": True}, + ], + ), + # Add override removing another override spanning over smaller period + ( + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 06:00", "action": True}, + {"start": "2023-08-24 15:00", "end": "2023-08-24 20:00", "action": True}, + ], + {"start": "2023-08-24 15:00", "end": "2023-08-24 22:00", "action": False}, + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 06:00", "action": True}, + {"start": "2023-08-24 15:00", "end": "2023-08-24 22:00", "action": False}, + ], + ), + # Add override within existing override span, splitting it + ( + [{"start": "2023-08-24 08:00", "end": "2023-08-24 20:00", "action": False}], + {"start": "2023-08-24 16:00", "end": "2023-08-24 18:00", "action": True}, + [ + {"start": "2023-08-24 08:00", "end": "2023-08-24 16:00", "action": False}, + {"start": "2023-08-24 16:00", "end": "2023-08-24 18:00", "action": True}, + {"start": "2023-08-24 18:00", "end": "2023-08-24 20:00", "action": False}, + ], + ), + # Add override moving end and start of existing overrides + ( + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 06:00", "action": True}, + {"start": "2023-08-24 15:00", "end": "2023-08-24 20:00", "action": True}, + ], + {"start": "2023-08-24 04:00", "end": "2023-08-24 18:00", "action": False}, + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 04:00", "action": True}, + {"start": "2023-08-24 04:00", "end": "2023-08-24 18:00", "action": False}, + {"start": "2023-08-24 18:00", "end": "2023-08-24 20:00", "action": True}, + ], + ), + # Add override merging existing overrides + ( + [ + {"start": "2023-08-24 00:00", "end": "2023-08-24 06:00", "action": True}, + {"start": "2023-08-24 15:00", "end": "2023-08-24 20:00", "action": True}, + ], + {"start": "2023-08-24 04:00", "end": "2023-08-24 18:00", "action": True}, + [{"start": "2023-08-24 00:00", "end": "2023-08-24 20:00", "action": True}], + ), + ], +) +def test_update_overrides(overrides, span, expected_result): + overrides = [DateTimeSpan.from_args(**o) for o in overrides] + span = DateTimeSpan.from_args(**span) + + app.update_overrides(overrides, span) + + expected_result = [DateTimeSpan.from_args(**o) for o in expected_result] + assert overrides == expected_result diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..0d22bea --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[coverage:run] +branch = True + +[tox] +envlist = pylint, pytest, black + +[testenv:pylint] +deps = pylint +commands = pylint parental_control tests {posargs} + +[testenv:pytest] +deps = pytest-cov +commands = pytest tests -vv --cov parental_control --cov-report term --cov-report xml --cov-fail-under 100 {posargs} + +[testenv:black] +deps = black +commands = black -l 100 --check --diff parental_control tests {posargs} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..33978fc --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +#!/usr/bin/python3 + +from api.app import app as application