Initial commit
This commit is contained in:
commit
705c85177a
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
244
api/app.py
Normal file
244
api/app.py
Normal file
@ -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/<int:minutes>")
|
||||
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()
|
144
api/data.py
Normal file
144
api/data.py
Normal file
@ -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)
|
5
client/Installer/Installer.wixproj
Normal file
5
client/Installer/Installer.wixproj
Normal file
@ -0,0 +1,5 @@
|
||||
<Project Sdk="WixToolset.Sdk/4.0.0">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ParentalControlService\ParentalControlService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
41
client/Installer/Package.wxs
Normal file
41
client/Installer/Package.wxs
Normal file
@ -0,0 +1,41 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Marek Parental Control" Manufacturer="Disassembler" Version="1.0.0.0" UpgradeCode="feb429a6-684e-4506-816f-006384136754" Compressed="true">
|
||||
<!-- Allow upgrades and prevent downgrades -->
|
||||
<MajorUpgrade DowngradeErrorMessage="A later version of [ProductName] is already installed. Setup will now exit." />
|
||||
|
||||
<!-- Define the directory structure -->
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLDIR" Name="Marek Parental Control" />
|
||||
</StandardDirectory>
|
||||
|
||||
<DirectoryRef Id="INSTALLDIR">
|
||||
<Component Id="ServiceExecutable" Bitness="always64">
|
||||
<File Id="ParentalControlService.exe"
|
||||
Source="$(var.ParentalControlService.TargetDir)publish\ParentalControlService.exe"
|
||||
KeyPath="true" />
|
||||
|
||||
<RemoveFile Id="ALLFILES" Name="*.*" On="both" />
|
||||
|
||||
<ServiceInstall Id="ServiceInstaller"
|
||||
Type="ownProcess"
|
||||
Name="ParentalControlService"
|
||||
DisplayName="Marek Parental Control"
|
||||
Description="Marek Parental Control"
|
||||
Start="auto"
|
||||
ErrorControl="normal" />
|
||||
|
||||
<ServiceControl Id="StartService"
|
||||
Start="install"
|
||||
Stop="both"
|
||||
Remove="uninstall"
|
||||
Name="ParentalControlService"
|
||||
Wait="true" />
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<Feature Id="Service" Title="Marek Parental Control Setup" Level="1">
|
||||
<ComponentRef Id="ServiceExecutable" />
|
||||
</Feature>
|
||||
|
||||
</Package>
|
||||
</Wix>
|
61
client/ParentalControlService.sln
Normal file
61
client/ParentalControlService.sln
Normal file
@ -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
|
69
client/ParentalControlService/Data.cs
Normal file
69
client/ParentalControlService/Data.cs
Normal file
@ -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<int,List<Schedule>> Schedules { get; set; }
|
||||
[JsonPropertyName("overrides")]
|
||||
public List<Override> 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; }
|
||||
}
|
||||
}
|
18
client/ParentalControlService/ParentalControlService.csproj
Normal file
18
client/ParentalControlService/ParentalControlService.csproj
Normal file
@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-ParentalControlService-c499de7d-03ae-4d69-a7cf-e634d0dce29d</UserSecretsId>
|
||||
<OutputType>exe</OutputType>
|
||||
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Version>1.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<_LastSelectedProfileId>C:\Users\Disassembler\Desktop\client\ParentalControlService\Properties\PublishProfiles\FolderProfile.pubxml</_LastSelectedProfileId>
|
||||
</PropertyGroup>
|
||||
</Project>
|
134
client/ParentalControlService/ParentalControlSvc.cs
Normal file
134
client/ParentalControlService/ParentalControlSvc.cs
Normal file
@ -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<ParentalControlSvc> _logger;
|
||||
private Config config;
|
||||
private Status status;
|
||||
private bool currentAction;
|
||||
private readonly Timer checkTimer;
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
public ParentalControlSvc(ILogger<ParentalControlSvc> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
// Load status
|
||||
try {
|
||||
status = JsonSerializer.Deserialize<Status>(File.ReadAllText(statusPath))!;
|
||||
}
|
||||
catch (FileNotFoundException) {
|
||||
status = new Status() { Action = true, Until = DateTime.MaxValue };
|
||||
}
|
||||
currentAction = status.Action;
|
||||
|
||||
// Load config
|
||||
try
|
||||
{
|
||||
config = JsonSerializer.Deserialize<Config>(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<Data>("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();
|
||||
}
|
||||
}
|
20
client/ParentalControlService/Program.cs
Normal file
20
client/ParentalControlService/Program.cs
Normal file
@ -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<EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||
|
||||
builder.Services.AddSingleton<ParentalControlSvc>();
|
||||
builder.Services.AddHostedService<ParentalControlSvc>();
|
||||
builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
|
||||
|
||||
IHost host = builder.Build();
|
||||
host.Run();
|
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>bin\Release\net7.0-windows\win-x64\publish\</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<TargetFramework>net7.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
11
client/ParentalControlService/Properties/launchSettings.json
Normal file
11
client/ParentalControlService/Properties/launchSettings.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"profiles": {
|
||||
"ParentalControlService": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
8
client/ParentalControlService/appsettings.json
Normal file
8
client/ParentalControlService/appsettings.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
8
client/block.ps1
Normal file
8
client/block.ps1
Normal file
@ -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
|
||||
}
|
5
client/config.json
Normal file
5
client/config.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"url": "https://pc.dasm.cz",
|
||||
"username": "parent",
|
||||
"password": "DA2ptE{!my_hSN6="
|
||||
}
|
1
client/unblock.ps1
Normal file
1
client/unblock.ps1
Normal file
@ -0,0 +1 @@
|
||||
C:\Windows\System32\net.exe user Marek /active:yes
|
1
data.json
Normal file
1
data.json
Normal file
@ -0,0 +1 @@
|
||||
{"schedules": {"0": [], "1": [], "2": [], "3": [], "4": [], "5": [], "6": []}, "overrides": []}
|
71
index.html
Normal file
71
index.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<title>Parental Control</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||
<link rel="stylesheet" href="static/style.css" type="text/css" media="all">
|
||||
<script type="text/javascript" src="static/script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<h2>Stav</h2>
|
||||
<div id="status"> </div>
|
||||
<input class="fast-action" data-mins="15" type="button" value="+15 min">
|
||||
<input class="fast-action" data-mins="30" type="button" value="+30 min">
|
||||
<input class="fast-action" data-mins="60" type="button" value="+1 hod">
|
||||
<input class="fast-action" data-mins="0" type="button" value="Vypnout hned">
|
||||
</div>
|
||||
<div>
|
||||
<h2>Výjimky</h2>
|
||||
<div id="overrides"></div>
|
||||
<form id="add-override">
|
||||
<label for="override-action">Akce:</label>
|
||||
<select id="override-action">
|
||||
<option value="allow">Povolit</option>
|
||||
<option value="deny">Zakázat</option>
|
||||
</select><br>
|
||||
<label for="override-start">Od:</label>
|
||||
<input id="override-start" type="datetime-local"><br>
|
||||
<label for="override-end">Do:</label>
|
||||
<input id="override-end" type="datetime-local"><br>
|
||||
<input type="submit" value="Přidat">
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Plán</h2>
|
||||
<div id="schedules">
|
||||
<span>Pondělí:</span><span id="schedule-0"></span>
|
||||
<span>Úterý:</span><span id="schedule-1"></span>
|
||||
<span>Středa:</span><span id="schedule-2"></span>
|
||||
<span>Čtvrtek:</span><span id="schedule-3"></span>
|
||||
<span>Pátek:</span><span id="schedule-4"></span>
|
||||
<span>Sobota:</span><span id="schedule-5"></span>
|
||||
<span>Neděle:</span><span id="schedule-6"></span>
|
||||
</div>
|
||||
<form id="update-schedule">
|
||||
<label for="schedule-action">Akce:</label>
|
||||
<select id="schedule-action">
|
||||
<option value="allow">Povolit</option>
|
||||
<option value="deny">Zakázat</option>
|
||||
</select>
|
||||
<label for="schedule-days">Den:</label>
|
||||
<select id="schedule-days" size="7" multiple>
|
||||
<option value="0">Pondělí</option>
|
||||
<option value="1">Úterý</option>
|
||||
<option value="2">Středa</option>
|
||||
<option value="3">Čtvrtek</option>
|
||||
<option value="4">Pátek</option>
|
||||
<option value="5">Sobota</option>
|
||||
<option value="6">Neděle</option>
|
||||
</select>
|
||||
<label for="schedule-start">Od:</label>
|
||||
<input id="schedule-start" type="time">
|
||||
<label for="schedule-end">Do:</label>
|
||||
<input id="schedule-end" type="time">
|
||||
<input type="submit" value="Přidat">
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@ -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"]
|
122
static/script.js
Normal file
122
static/script.js
Normal file
@ -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);
|
||||
});
|
65
static/style.css
Normal file
65
static/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
389
tests/test_app.py
Normal file
389
tests/test_app.py
Normal file
@ -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
|
17
tox.ini
Normal file
17
tox.ini
Normal file
@ -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}
|
Loading…
Reference in New Issue
Block a user