diff --git a/Makefile b/Makefile
index 860e194..dcc4c53 100644
--- a/Makefile
+++ b/Makefile
@@ -30,6 +30,9 @@ help:
# Local development
# =================
+db:
+ docker compose up -d
+
server:
$(DEV_CMD) runserver 0.0.0.0:4672
diff --git a/intbot/core/bot/main.py b/intbot/core/bot/main.py
index a5da5a1..3bc7e7f 100644
--- a/intbot/core/bot/main.py
+++ b/intbot/core/bot/main.py
@@ -189,6 +189,16 @@ async def poll_database():
print("Channel does not exist!")
+@bot.command()
+async def until(ctx):
+ """
+ Returns time left until the conference
+ """
+ delta = settings.CONFERENCE_START - timezone.now()
+
+ await ctx.send(f"{delta.days} days left until the conference")
+
+
def run_bot():
bot_token = settings.DISCORD_BOT_TOKEN
bot.run(bot_token)
diff --git a/intbot/core/views.py b/intbot/core/views.py
new file mode 100644
index 0000000..0dbfea5
--- /dev/null
+++ b/intbot/core/views.py
@@ -0,0 +1,9 @@
+from django.conf import settings
+from django.template.response import TemplateResponse
+from django.utils import timezone
+
+
+def days_until(request):
+ delta = settings.CONFERENCE_START - timezone.now()
+
+ return TemplateResponse(request, "days_until.html", {"days_until": delta.days})
diff --git a/intbot/intbot/settings.py b/intbot/intbot/settings.py
index 8df473c..1a7b907 100644
--- a/intbot/intbot/settings.py
+++ b/intbot/intbot/settings.py
@@ -12,8 +12,9 @@
import os
import warnings
-from typing import Any
+from datetime import datetime, timezone
from pathlib import Path
+from typing import Any
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -52,7 +53,9 @@
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
- "DIRS": [],
+ "DIRS": [
+ BASE_DIR / "templates",
+ ],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@@ -117,6 +120,9 @@
TASKS: dict[str, Any]
+CONFERENCE_START = datetime(2025, 7, 14, tzinfo=timezone.utc)
+
+
# There are bunch of settings that we can skip on dev/testing environments if
# not used - that should be always present on prod/staging deployments.
# Instead of repeating them per-env below, they go here.
diff --git a/intbot/intbot/urls.py b/intbot/intbot/urls.py
index f19e6d8..19237fe 100644
--- a/intbot/intbot/urls.py
+++ b/intbot/intbot/urls.py
@@ -4,14 +4,17 @@
internal_webhook_endpoint,
zammad_webhook_endpoint,
)
+from core.views import days_until
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
path("", index),
- # Internal Webhooks
+ # Webhooks
path("webhook/internal/", internal_webhook_endpoint),
path("webhook/github/", github_webhook_endpoint),
path("webhook/zammad/", zammad_webhook_endpoint),
+ # Public Pages
+ path("days-until/", days_until),
]
diff --git a/intbot/templates/days_until.html b/intbot/templates/days_until.html
new file mode 100644
index 0000000..296c7f1
--- /dev/null
+++ b/intbot/templates/days_until.html
@@ -0,0 +1,9 @@
+
+
+
+ {{ days_until }} days until the conference
+
+
diff --git a/intbot/tests/test_bot/test_main.py b/intbot/tests/test_bot/test_main.py
index 8c34fe5..012dc4e 100644
--- a/intbot/tests/test_bot/test_main.py
+++ b/intbot/tests/test_bot/test_main.py
@@ -1,11 +1,12 @@
from unittest.mock import AsyncMock, patch
-import discord
+import discord
import pytest
from asgiref.sync import sync_to_async
-from core.bot.main import ping, poll_database, qlen, source, version, wiki, close
+from core.bot.main import close, ping, poll_database, qlen, source, until, version, wiki
from core.models import DiscordMessage
from django.utils import timezone
+from freezegun import freeze_time
@pytest.mark.asyncio
@@ -189,3 +190,13 @@ async def test_polling_messages_sends_message_if_not_sent_and_sets_sent_at():
assert dm.sent_at is not None
end = timezone.now()
assert start < dm.sent_at < end
+
+
+@pytest.mark.asyncio
+@freeze_time("2025-04-05")
+async def test_until():
+ ctx = AsyncMock()
+
+ await until(ctx)
+
+ ctx.send.assert_called_once_with("100 days left until the conference")
diff --git a/intbot/tests/test_views.py b/intbot/tests/test_views.py
new file mode 100644
index 0000000..e18c577
--- /dev/null
+++ b/intbot/tests/test_views.py
@@ -0,0 +1,8 @@
+from pytest_django.asserts import assertTemplateUsed
+
+
+def test_days_until_view(client):
+ response = client.get("/days-until/")
+
+ assert response.status_code == 200
+ assertTemplateUsed(response, "days_until.html")
diff --git a/pyproject.toml b/pyproject.toml
index 6a178d5..c7d0330 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ dependencies = [
"pytest-socket>=0.7.0",
"respx>=0.22.0",
"pydantic>=2.10.6",
+ "freezegun>=1.5.1",
]
[tool.pytest.ini_options]
diff --git a/uv.lock b/uv.lock
index cc64c0c..bbe66b9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -225,6 +225,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/ef/c08926112034d017633f693d3afc8343393a035134a29dfc12dcd71b0375/fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080", size = 9681 },
]
+[[package]]
+name = "freezegun"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 },
+]
+
[[package]]
name = "frozenlist"
version = "1.5.0"
@@ -326,6 +338,7 @@ dependencies = [
{ name = "django-extensions" },
{ name = "django-stubs" },
{ name = "django-tasks" },
+ { name = "freezegun" },
{ name = "gunicorn" },
{ name = "httpx" },
{ name = "mypy" },
@@ -349,6 +362,7 @@ requires-dist = [
{ name = "django-extensions", specifier = ">=3.2.3" },
{ name = "django-stubs", specifier = ">=5.1.1" },
{ name = "django-tasks", specifier = ">=0.6.1" },
+ { name = "freezegun", specifier = ">=1.5.1" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=1.14.1" },
@@ -611,6 +625,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754 },
]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
+]
+
[[package]]
name = "respx"
version = "0.22.0"
@@ -648,6 +674,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/f8/3765e053acd07baa055c96b2065c7fab91f911b3c076dfea71006666f5b0/ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162", size = 9149556 },
]
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"