diff --git a/Dockerfile b/Dockerfile index 1610613..bee9fba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,7 @@ COPY invest/ ./invest/ COPY netscan/ ./netscan/ COPY nginxmon/ ./nginxmon/ COPY routermon/ ./routermon/ +COPY pricemon/ ./pricemon/ COPY new_theme/ ./new_theme/ COPY templates/ ./templates/ COPY locale/ ./locale/ @@ -146,6 +147,7 @@ COPY --chown=appuser:appuser invest/ ./invest/ COPY --chown=appuser:appuser netscan/ ./netscan/ COPY --chown=appuser:appuser nginxmon/ ./nginxmon/ COPY --chown=appuser:appuser routermon/ ./routermon/ +COPY --chown=appuser:appuser pricemon/ ./pricemon/ COPY --chown=appuser:appuser new_theme/ ./new_theme/ COPY --chown=appuser:appuser templates/ ./templates/ COPY --chown=appuser:appuser static/ ./static/ diff --git a/core/settings.py b/core/settings.py index ffa65e3..af0927c 100644 --- a/core/settings.py +++ b/core/settings.py @@ -24,6 +24,7 @@ INSTALLED_APPS = [ 'netscan', 'nginxmon', 'routermon', + 'pricemon', ] ROOT_URLCONF = 'core.urls' diff --git a/core/urls.py b/core/urls.py index a57227e..b1205b8 100644 --- a/core/urls.py +++ b/core/urls.py @@ -53,6 +53,7 @@ urlpatterns = [ path('ui/netscan/', include('netscan.urls')), path('ui/nginxmon/', include('nginxmon.urls')), path('ui/routermon/', include('routermon.urls')), + path('ui/pricemon/', include('pricemon.urls')), path('ui/files/', include('links.file_urls')), # Import external image by URL — /import/images/ (also plural alias) diff --git a/links/migrations/0049_sitesettings_telegram_bot_token_and_more.py b/links/migrations/0049_sitesettings_telegram_bot_token_and_more.py new file mode 100644 index 0000000..63ab00b --- /dev/null +++ b/links/migrations/0049_sitesettings_telegram_bot_token_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.12 on 2026-05-11 11:17 + +import netscan.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0048_remove_knowledge_graph'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='telegram_bot_token', + field=netscan.fields.EncryptedCharField(blank=True, help_text='Global Telegram bot token (from @BotFather). Used by all apps that send Telegram alerts.'), + ), + migrations.AddField( + model_name='sitesettings', + name='telegram_chat_id', + field=netscan.fields.EncryptedCharField(blank=True, help_text='Telegram chat ID to receive alerts. Get yours from @userinfobot.'), + ), + ] diff --git a/links/models.py b/links/models.py index f2b6a7f..c46f62e 100644 --- a/links/models.py +++ b/links/models.py @@ -12,6 +12,7 @@ from datetime import timedelta from urllib.parse import urlparse from django.utils.text import slugify import random +from netscan.fields import EncryptedCharField logger = logging.getLogger(__name__) @@ -417,6 +418,14 @@ class SiteSettings(models.Model): Always use SiteSettings.get() to retrieve the instance. """ + telegram_bot_token = EncryptedCharField( + blank=True, + help_text=_('Global Telegram bot token (from @BotFather). Used by all apps that send Telegram alerts.'), + ) + telegram_chat_id = EncryptedCharField( + blank=True, + help_text=_('Telegram chat ID to receive alerts. Get yours from @userinfobot.'), + ) public_sharing_domain = models.CharField( _('Public Sharing Domain'), max_length=255, diff --git a/links/templates/links/settings.html b/links/templates/links/settings.html index 76650e6..3fbc438 100644 --- a/links/templates/links/settings.html +++ b/links/templates/links/settings.html @@ -15,6 +15,40 @@
+ +
+

{% trans "Telegram Notifications" %}

+

+ {% trans "Global Telegram credentials used by all apps (Price Monitor, NetScan, etc.) to send alerts." %} + {% trans "Create a bot via" %} @BotFather + {% trans "and get your chat ID from" %} @userinfobot. +

+
+
+ + +

{% trans "Stored encrypted at rest." %}

+
+
+ + +

{% trans "User ID or group chat ID." %}

+
+
+
+

{% trans "Public Sharing Domain" %}

diff --git a/links/views.py b/links/views.py index c7d2538..ef426e8 100644 --- a/links/views.py +++ b/links/views.py @@ -578,6 +578,8 @@ class SiteSettingsView(View): from core.scheduler import scheduler site_settings = SiteSettings.get() + site_settings.telegram_bot_token = request.POST.get('telegram_bot_token', '').strip() + site_settings.telegram_chat_id = request.POST.get('telegram_chat_id', '').strip() site_settings.public_sharing_domain = request.POST.get('public_sharing_domain', '').strip() try: max_jobs = int(request.POST.get('max_concurrent_screenshot_jobs', 2)) diff --git a/pricemon/__init__.py b/pricemon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pricemon/admin.py b/pricemon/admin.py new file mode 100644 index 0000000..61c43d7 --- /dev/null +++ b/pricemon/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from pricemon.models import PriceAlert, PriceSnapshot, PriceWatcher + + +@admin.register(PriceWatcher) +class PriceWatcherAdmin(admin.ModelAdmin): + list_display = ['name', 'url', 'last_price', 'last_checked_at', 'enabled', 'check_interval_hours'] + list_filter = ['enabled', 'check_interval_hours'] + search_fields = ['name', 'url'] + + +@admin.register(PriceSnapshot) +class PriceSnapshotAdmin(admin.ModelAdmin): + list_display = ['watcher', 'price', 'raw_text', 'error', 'checked_at'] + list_filter = ['watcher'] + readonly_fields = ['checked_at'] + + +@admin.register(PriceAlert) +class PriceAlertAdmin(admin.ModelAdmin): + list_display = ['watcher', 'old_price', 'new_price', 'drop_pct', 'dismissed', 'created_at'] + list_filter = ['dismissed', 'watcher'] + readonly_fields = ['created_at'] diff --git a/pricemon/apps.py b/pricemon/apps.py new file mode 100644 index 0000000..79f2586 --- /dev/null +++ b/pricemon/apps.py @@ -0,0 +1,21 @@ +import logging + +from django.apps import AppConfig + +logger = logging.getLogger(__name__) + + +class PricemonConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'pricemon' + + def ready(self): + import pricemon.signals # noqa: F401 + try: + from pricemon.tasks import schedule_watcher + from pricemon.models import PriceWatcher + for watcher in PriceWatcher.objects.filter(enabled=True): + schedule_watcher(watcher) + logger.info(f'Scheduled pricemon watcher: {watcher.name}') + except Exception as exc: + logger.warning(f'Could not schedule pricemon watchers on startup: {exc}') diff --git a/pricemon/forms.py b/pricemon/forms.py new file mode 100644 index 0000000..8809dc8 --- /dev/null +++ b/pricemon/forms.py @@ -0,0 +1,27 @@ +from django import forms +from pricemon.models import PriceWatcher + +_input = 'w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500' +_mono = _input + ' font-mono' +_select = _input + + +class PriceWatcherForm(forms.ModelForm): + class Meta: + model = PriceWatcher + fields = [ + 'name', 'url', 'css_selector', 'check_interval_hours', + 'alert_threshold_pct', 'enabled', + ] + widgets = { + 'name': forms.TextInput(attrs={'class': _input}), + 'url': forms.URLInput(attrs={'class': _input}), + 'css_selector': forms.TextInput(attrs={'class': _mono, 'placeholder': 'e.g. span.price or .product-price'}), + 'check_interval_hours': forms.Select(attrs={'class': _select}), + 'alert_threshold_pct': forms.NumberInput(attrs={'class': _input, 'step': '0.01', 'min': '0'}), + 'enabled': forms.CheckboxInput(attrs={'class': 'h-4 w-4 text-indigo-600 border-gray-300 rounded'}), + } + help_texts = { + 'css_selector': 'CSS selector pointing to the element containing the price text.', + 'alert_threshold_pct': 'Minimum percentage drop to send an alert. Use 0 to alert on any drop.', + } diff --git a/pricemon/migrations/0001_initial.py b/pricemon/migrations/0001_initial.py new file mode 100644 index 0000000..e70618c --- /dev/null +++ b/pricemon/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.12 on 2026-05-11 10:59 + +import django.db.models.deletion +import netscan.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PriceWatcher', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('url', models.URLField(max_length=2000)), + ('css_selector', models.CharField(help_text='CSS selector for the price element, e.g. span.price or .product-price', max_length=500)), + ('check_interval_hours', models.IntegerField(choices=[(6, 'Every 6 hours'), (12, 'Every 12 hours'), (24, 'Every day'), (48, 'Every 2 days'), (72, 'Every 3 days'), (168, 'Every 7 days')], default=24)), + ('alert_threshold_pct', models.DecimalField(decimal_places=2, default=0, help_text='Minimum % price drop to trigger an alert (0 = any drop)', max_digits=5)), + ('telegram_bot_token', netscan.fields.EncryptedCharField(blank=True)), + ('telegram_chat_id', netscan.fields.EncryptedCharField(blank=True)), + ('enabled', models.BooleanField(default=True)), + ('last_checked_at', models.DateTimeField(blank=True, null=True)), + ('last_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='PriceSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('raw_text', models.CharField(blank=True, max_length=500)), + ('error', models.CharField(blank=True, max_length=500)), + ('checked_at', models.DateTimeField(auto_now_add=True)), + ('watcher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='pricemon.pricewatcher')), + ], + options={ + 'ordering': ['-checked_at'], + }, + ), + migrations.CreateModel( + name='PriceAlert', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_price', models.DecimalField(decimal_places=2, max_digits=12)), + ('new_price', models.DecimalField(decimal_places=2, max_digits=12)), + ('drop_pct', models.DecimalField(decimal_places=2, max_digits=5)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('dismissed', models.BooleanField(default=False)), + ('watcher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='pricemon.pricewatcher')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/pricemon/migrations/0002_pricemonsettings_and_more.py b/pricemon/migrations/0002_pricemonsettings_and_more.py new file mode 100644 index 0000000..6c50494 --- /dev/null +++ b/pricemon/migrations/0002_pricemonsettings_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.12 on 2026-05-11 11:10 + +import netscan.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pricemon', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PricemonSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('telegram_bot_token', netscan.fields.EncryptedCharField(blank=True)), + ('telegram_chat_id', netscan.fields.EncryptedCharField(blank=True)), + ], + options={ + 'verbose_name': 'Pricemon Settings', + 'verbose_name_plural': 'Pricemon Settings', + }, + ), + migrations.RemoveField( + model_name='pricewatcher', + name='telegram_bot_token', + ), + migrations.RemoveField( + model_name='pricewatcher', + name='telegram_chat_id', + ), + ] diff --git a/pricemon/migrations/0003_delete_pricemonsettings.py b/pricemon/migrations/0003_delete_pricemonsettings.py new file mode 100644 index 0000000..3ff3066 --- /dev/null +++ b/pricemon/migrations/0003_delete_pricemonsettings.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pricemon', '0002_pricemonsettings_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='PricemonSettings', + ), + ] diff --git a/pricemon/migrations/__init__.py b/pricemon/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pricemon/models.py b/pricemon/models.py new file mode 100644 index 0000000..2f9d4b2 --- /dev/null +++ b/pricemon/models.py @@ -0,0 +1,63 @@ +from django.db import models + + +class PriceWatcher(models.Model): + INTERVAL_CHOICES = [ + (6, 'Every 6 hours'), + (12, 'Every 12 hours'), + (24, 'Every day'), + (48, 'Every 2 days'), + (72, 'Every 3 days'), + (168, 'Every 7 days'), + ] + + name = models.CharField(max_length=200) + url = models.URLField(max_length=2000) + css_selector = models.CharField( + max_length=500, + help_text='CSS selector for the price element, e.g. span.price or .product-price', + ) + check_interval_hours = models.IntegerField(choices=INTERVAL_CHOICES, default=24) + alert_threshold_pct = models.DecimalField( + max_digits=5, decimal_places=2, default=0, + help_text='Minimum % price drop to trigger an alert (0 = any drop)', + ) + enabled = models.BooleanField(default=True) + last_checked_at = models.DateTimeField(null=True, blank=True) + last_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + +class PriceSnapshot(models.Model): + watcher = models.ForeignKey(PriceWatcher, on_delete=models.CASCADE, related_name='snapshots') + price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + raw_text = models.CharField(max_length=500, blank=True) + error = models.CharField(max_length=500, blank=True) + checked_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-checked_at'] + + def __str__(self): + return f'{self.watcher.name} @ {self.checked_at:%Y-%m-%d %H:%M}' + + +class PriceAlert(models.Model): + watcher = models.ForeignKey(PriceWatcher, on_delete=models.CASCADE, related_name='alerts') + old_price = models.DecimalField(max_digits=12, decimal_places=2) + new_price = models.DecimalField(max_digits=12, decimal_places=2) + drop_pct = models.DecimalField(max_digits=5, decimal_places=2) + created_at = models.DateTimeField(auto_now_add=True) + dismissed = models.BooleanField(default=False) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f'{self.watcher.name}: ${self.old_price} → ${self.new_price}' diff --git a/pricemon/notifications.py b/pricemon/notifications.py new file mode 100644 index 0000000..7f001ba --- /dev/null +++ b/pricemon/notifications.py @@ -0,0 +1,57 @@ +import logging + +import requests + +logger = logging.getLogger(__name__) + + +def _get_telegram_credentials(): + from links.models import SiteSettings + cfg = SiteSettings.get() + return cfg.telegram_bot_token, cfg.telegram_chat_id + + +def notify_telegram(watcher, alert) -> None: + token, chat_id = _get_telegram_credentials() + if not token or not chat_id: + logger.warning('Pricemon: no global Telegram credentials configured, skipping alert') + return + + text = ( + f'\U0001f4b0 *Price Drop Alert\\!*\n' + f'*{_esc(watcher.name)}*\n' + f'${alert.old_price} → *${alert.new_price}* \\({float(alert.drop_pct):.1f}% off\\)\n' + f'[View Product]({watcher.url})' + ) + try: + resp = requests.post( + f'https://api.telegram.org/bot{token}/sendMessage', + json={'chat_id': chat_id, 'text': text, 'parse_mode': 'MarkdownV2'}, + timeout=10, + ) + resp.raise_for_status() + logger.info(f'Telegram alert sent for watcher {watcher.pk}') + except Exception as exc: + logger.error(f'Failed to send Telegram alert for watcher {watcher.pk}: {exc}') + + +def send_test_telegram() -> dict: + token, chat_id = _get_telegram_credentials() + if not token or not chat_id: + return {'ok': False, 'error': 'No Telegram credentials in global Settings.'} + try: + resp = requests.post( + f'https://api.telegram.org/bot{token}/sendMessage', + json={'chat_id': chat_id, 'text': '\u2705 *PriceMon test* — notifications are working\\!', 'parse_mode': 'MarkdownV2'}, + timeout=10, + ) + resp.raise_for_status() + return {'ok': True} + except Exception as exc: + return {'ok': False, 'error': str(exc)} + + +def _esc(text: str) -> str: + for ch in r'\_*[]()~`>#+-=|{}.!': + text = text.replace(ch, f'\\{ch}') + return text diff --git a/pricemon/scraper.py b/pricemon/scraper.py new file mode 100644 index 0000000..9621ff6 --- /dev/null +++ b/pricemon/scraper.py @@ -0,0 +1,80 @@ +import logging +import re +from decimal import Decimal, InvalidOperation + +import requests +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + +_HEADERS = { + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36' + ), + 'Accept-Language': 'en-AU,en;q=0.9', +} + + +def parse_price(text: str) -> Decimal | None: + """Extract the first numeric price from a string.""" + if not text: + return None + cleaned = text.strip().replace(',', '') + match = re.search(r'\d+\.?\d*', cleaned) + if not match: + return None + try: + return Decimal(match.group()) + except InvalidOperation: + return None + + +def _fetch_with_requests(url: str, css_selector: str) -> tuple[str, str | None]: + """Returns (raw_text, error). raw_text is empty string on failure.""" + try: + resp = requests.get(url, headers=_HEADERS, timeout=15) + resp.raise_for_status() + soup = BeautifulSoup(resp.content, 'html.parser') + el = soup.select_one(css_selector) + if el: + return el.get_text(strip=True), None + return '', f'Selector "{css_selector}" matched no element' + except Exception as exc: + return '', str(exc) + + +def _fetch_with_playwright(url: str, css_selector: str) -> tuple[str, str | None]: + """Fallback for JS-rendered pages.""" + try: + from playwright.sync_api import sync_playwright + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(extra_http_headers={'Accept-Language': 'en-AU,en;q=0.9'}) + page.goto(url, wait_until='domcontentloaded', timeout=30000) + page.wait_for_timeout(2000) + el = page.query_selector(css_selector) + text = el.inner_text() if el else '' + browser.close() + if not text: + return '', f'Selector "{css_selector}" matched no element (playwright)' + return text.strip(), None + except Exception as exc: + return '', f'Playwright error: {exc}' + + +def fetch_price(watcher) -> tuple[Decimal | None, str, str | None]: + """ + Returns (price, raw_text, error). + Tries requests first; falls back to Playwright if the element isn't found. + """ + raw_text, error = _fetch_with_requests(watcher.url, watcher.css_selector) + + if not raw_text and not error: + logger.info(f'Falling back to Playwright for watcher {watcher.pk}') + raw_text, error = _fetch_with_playwright(watcher.url, watcher.css_selector) + + price = parse_price(raw_text) if raw_text else None + logger.info(f'Watcher {watcher.pk}: raw="{raw_text}" price={price} error={error}') + return price, raw_text, error diff --git a/pricemon/signals.py b/pricemon/signals.py new file mode 100644 index 0000000..b0e77fd --- /dev/null +++ b/pricemon/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + + +@receiver(post_save, sender='pricemon.PriceWatcher') +def reschedule_on_save(sender, instance, **kwargs): + from pricemon.tasks import schedule_watcher, unschedule_watcher + if instance.enabled: + schedule_watcher(instance) + else: + unschedule_watcher(instance) + + +@receiver(post_delete, sender='pricemon.PriceWatcher') +def unschedule_on_delete(sender, instance, **kwargs): + from pricemon.tasks import unschedule_watcher + unschedule_watcher(instance) diff --git a/pricemon/tasks.py b/pricemon/tasks.py new file mode 100644 index 0000000..a73d0fa --- /dev/null +++ b/pricemon/tasks.py @@ -0,0 +1,67 @@ +import logging + +from apscheduler.triggers.interval import IntervalTrigger +from django.utils.timezone import now + +from core.scheduler import scheduler + +logger = logging.getLogger(__name__) + + +def check_watcher(watcher_pk: int) -> None: + from pricemon.models import PriceWatcher, PriceSnapshot, PriceAlert + from pricemon.scraper import fetch_price + from pricemon.notifications import notify_telegram + + try: + watcher = PriceWatcher.objects.get(pk=watcher_pk) + except PriceWatcher.DoesNotExist: + logger.warning(f'PriceWatcher {watcher_pk} no longer exists, skipping') + return + + price, raw_text, error = fetch_price(watcher) + + PriceSnapshot.objects.create( + watcher=watcher, + price=price, + raw_text=raw_text or '', + error=error or '', + ) + + if price is not None and watcher.last_price is not None and price < watcher.last_price: + drop_pct = (watcher.last_price - price) / watcher.last_price * 100 + if drop_pct >= watcher.alert_threshold_pct: + alert = PriceAlert.objects.create( + watcher=watcher, + old_price=watcher.last_price, + new_price=price, + drop_pct=drop_pct, + ) + notify_telegram(watcher, alert) + + update_fields = ['last_checked_at'] + if price is not None: + watcher.last_price = price + update_fields.append('last_price') + watcher.last_checked_at = now() + watcher.save(update_fields=update_fields) + logger.info(f'Checked watcher {watcher_pk} ({watcher.name}): price={price}') + + +def schedule_watcher(watcher) -> None: + job_id = f'pricemon_watcher_{watcher.pk}' + scheduler.add_job( + check_watcher, + trigger=IntervalTrigger(hours=watcher.check_interval_hours), + id=job_id, + args=[watcher.pk], + replace_existing=True, + ) + logger.info(f'Scheduled pricemon job {job_id} every {watcher.check_interval_hours}h') + + +def unschedule_watcher(watcher) -> None: + job_id = f'pricemon_watcher_{watcher.pk}' + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + logger.info(f'Removed pricemon job {job_id}') diff --git a/pricemon/urls.py b/pricemon/urls.py new file mode 100644 index 0000000..3f33e14 --- /dev/null +++ b/pricemon/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from pricemon import views + +urlpatterns = [ + path('', views.DashboardView.as_view(), name='pricemon-dashboard'), + path('add/', views.WatcherCreateView.as_view(), name='pricemon-add'), + path('/edit/', views.WatcherEditView.as_view(), name='pricemon-edit'), + path('/delete/', views.WatcherDeleteView.as_view(), name='pricemon-delete'), + path('/history/', views.WatcherHistoryView.as_view(), name='pricemon-history'), + path('/check/', views.CheckNowView.as_view(), name='pricemon-check-now'), + path('api/alerts/', views.AlertsPartialView.as_view(), name='pricemon-alerts-partial'), + path('api/alerts//dismiss/', views.DismissAlertView.as_view(), name='pricemon-dismiss-alert'), + path('api/chart//', views.ChartDataView.as_view(), name='pricemon-chart-data'), + path('api/test-selector/', views.TestSelectorView.as_view(), name='pricemon-test-selector'), +] diff --git a/pricemon/views.py b/pricemon/views.py new file mode 100644 index 0000000..d2d5f84 --- /dev/null +++ b/pricemon/views.py @@ -0,0 +1,161 @@ +import logging + +from django.contrib import messages +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views import View +from django.views.generic import TemplateView + +from pricemon.forms import PriceWatcherForm +from pricemon.models import PriceAlert, PriceSnapshot, PriceWatcher + +logger = logging.getLogger(__name__) + + +class DashboardView(TemplateView): + template_name = 'pricemon/dashboard.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + watchers = PriceWatcher.objects.all() + active_alerts = PriceAlert.objects.filter(dismissed=False).select_related('watcher') + ctx['watchers'] = watchers + ctx['total_watchers'] = watchers.count() + ctx['active_alerts_count'] = active_alerts.count() + ctx['active_alerts'] = active_alerts[:10] + return ctx + + +class WatcherCreateView(View): + template_name = 'pricemon/watcher_form.html' + + def get(self, request): + return render(request, self.template_name, {'form': PriceWatcherForm(), 'title': 'Add Watcher'}) + + def post(self, request): + form = PriceWatcherForm(request.POST) + if form.is_valid(): + watcher = form.save() + messages.success(request, f'Watcher "{watcher.name}" created.') + return redirect('pricemon-dashboard') + return render(request, self.template_name, {'form': form, 'title': 'Add Watcher'}) + + +class WatcherEditView(View): + template_name = 'pricemon/watcher_form.html' + + def get(self, request, pk): + watcher = get_object_or_404(PriceWatcher, pk=pk) + return render(request, self.template_name, { + 'form': PriceWatcherForm(instance=watcher), + 'watcher': watcher, + 'title': f'Edit — {watcher.name}', + }) + + def post(self, request, pk): + watcher = get_object_or_404(PriceWatcher, pk=pk) + form = PriceWatcherForm(request.POST, instance=watcher) + if form.is_valid(): + form.save() + messages.success(request, f'Watcher "{watcher.name}" updated.') + return redirect('pricemon-dashboard') + return render(request, self.template_name, { + 'form': form, + 'watcher': watcher, + 'title': f'Edit — {watcher.name}', + }) + + +class WatcherDeleteView(View): + def post(self, request, pk): + watcher = get_object_or_404(PriceWatcher, pk=pk) + name = watcher.name + watcher.delete() + messages.success(request, f'Watcher "{name}" deleted.') + return redirect('pricemon-dashboard') + + +class WatcherHistoryView(TemplateView): + template_name = 'pricemon/history.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + watcher = get_object_or_404(PriceWatcher, pk=kwargs['pk']) + ctx['watcher'] = watcher + ctx['snapshots'] = watcher.snapshots.all()[:200] + return ctx + + +class CheckNowView(View): + def post(self, request, pk): + from pricemon.tasks import check_watcher + watcher = get_object_or_404(PriceWatcher, pk=pk) + try: + check_watcher(watcher.pk) + watcher.refresh_from_db() + msg = f'Check complete. Current price: ${watcher.last_price}' if watcher.last_price else 'Check complete (price not detected).' + if request.headers.get('HX-Request'): + return JsonResponse({'ok': True, 'message': msg}) + messages.success(request, msg) + except Exception as exc: + logger.error(f'Manual check failed for watcher {pk}: {exc}') + if request.headers.get('HX-Request'): + return JsonResponse({'ok': False, 'message': str(exc)}, status=500) + messages.error(request, f'Check failed: {exc}') + return redirect('pricemon-dashboard') + + +class AlertsPartialView(View): + def get(self, request): + alerts = PriceAlert.objects.filter(dismissed=False).select_related('watcher')[:20] + return render(request, 'pricemon/_alerts.html', {'alerts': alerts}) + + +class DismissAlertView(View): + def post(self, request, pk): + alert = get_object_or_404(PriceAlert, pk=pk) + alert.dismissed = True + alert.save(update_fields=['dismissed']) + if request.headers.get('HX-Request'): + return HttpResponse('') + return redirect('pricemon-dashboard') + + +class ChartDataView(View): + def get(self, request, pk): + watcher = get_object_or_404(PriceWatcher, pk=pk) + snapshots = ( + watcher.snapshots + .filter(price__isnull=False) + .values('checked_at', 'price') + .order_by('checked_at')[:500] + ) + labels = [s['checked_at'].strftime('%Y-%m-%d %H:%M') for s in snapshots] + prices = [float(s['price']) for s in snapshots] + return JsonResponse({'labels': labels, 'prices': prices, 'name': watcher.name}) + + +class TestSelectorView(View): + def post(self, request): + from pricemon.scraper import _fetch_with_requests, _fetch_with_playwright, parse_price + url = request.POST.get('url', '').strip() + css_selector = request.POST.get('css_selector', '').strip() + + if not url or not css_selector: + return render(request, 'pricemon/_selector_test_result.html', { + 'error': 'Both URL and CSS selector are required.', + }) + + raw_text, error = _fetch_with_requests(url, css_selector) + method = 'requests' + if not raw_text and not error: + raw_text, error = _fetch_with_playwright(url, css_selector) + method = 'playwright' + + price = parse_price(raw_text) if raw_text else None + return render(request, 'pricemon/_selector_test_result.html', { + 'raw_text': raw_text, + 'price': price, + 'error': error, + 'method': method, + }) diff --git a/templates/base.html b/templates/base.html index f20c54b..2ae27d5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -151,6 +151,16 @@
+ +
+ + + + {% trans "Price Monitor" %} +
+
+
@@ -275,6 +285,13 @@ + +{% endif %} diff --git a/templates/pricemon/_selector_test_result.html b/templates/pricemon/_selector_test_result.html new file mode 100644 index 0000000..586cdb5 --- /dev/null +++ b/templates/pricemon/_selector_test_result.html @@ -0,0 +1,25 @@ +{% if error %} +
+ + + + {{ error }} +
+{% elif price %} +
+ + + + + Price detected: ${{ price }} + (raw: "{{ raw_text }}", via {{ method }}) + +
+{% else %} +
+ + + + Element found ("{{ raw_text }}") but no numeric price could be parsed from it. +
+{% endif %} diff --git a/templates/pricemon/dashboard.html b/templates/pricemon/dashboard.html new file mode 100644 index 0000000..0e34066 --- /dev/null +++ b/templates/pricemon/dashboard.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Price Monitor" %}{% endblock %} + +{% block content %} +
+ + +
+
+

{% trans "Price Monitor" %}

+

{% trans "Track product prices and get notified on drops" %}

+
+ + + + + {% trans "Add Watcher" %} + + + + + + + +
+ + +
+
+
{% trans "Watchers" %}
+
{{ total_watchers }}
+
+
+
{% trans "Active Alerts" %}
+
+ {{ active_alerts_count }} +
+
+
+
{% trans "Enabled" %}
+
+ {% with watchers|length as total %} + {% for w in watchers %}{% if w.enabled %}{% endif %}{% endfor %} + {{ watchers|length }} + {% endwith %} +
+
+
+ + + {% if active_alerts %} +
+ {% include 'pricemon/_alerts.html' %} +
+ {% endif %} + + +
+ {% if watchers %} + + + + + + + + + + + + + {% for watcher in watchers %} + + + + + + + + + {% endfor %} + +
{% trans "Product" %}{% trans "Current Price" %}{% trans "Status" %}{% trans "Actions" %}
+
{{ watcher.name }}
+ + {{ watcher.url|truncatechars:60 }} + +
+ {% if watcher.last_price %} + ${{ watcher.last_price }} + {% else %} + + {% endif %} + + {% if watcher.enabled %} + {% trans "Active" %} + {% else %} + {% trans "Paused" %} + {% endif %} + +
+ + + + + + + + + + + + + + + +
+ {% csrf_token %} + +
+
+
+ {% else %} +
+ + + +

{% trans "No watchers yet" %}

+

+ {% trans "Add your first price watcher" %} +

+
+ {% endif %} +
+ +
+{% endblock %} diff --git a/templates/pricemon/history.html b/templates/pricemon/history.html new file mode 100644 index 0000000..52a16bd --- /dev/null +++ b/templates/pricemon/history.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{{ watcher.name }} — {% trans "Price History" %}{% endblock %} + +{% block content %} +
+ +
+ + + + + +

{{ watcher.name }}

+
+ {{ watcher.url|truncatechars:80 }} + + +
+
{% trans "Price History" %}
+
+
+ + +
+
+ {% trans "Recent Checks" %} +
+ {% if snapshots %} + + + + + + + + + + + {% for snap in snapshots %} + + + + + + + {% endfor %} + +
{% trans "Checked At" %}{% trans "Price" %}{% trans "Raw Text" %}{% trans "Error" %}
{{ snap.checked_at|date:"Y-m-d H:i" }} + {% if snap.price %}${{ snap.price }}{% else %}—{% endif %} + {{ snap.raw_text|truncatechars:40 }}{{ snap.error|truncatechars:60 }}
+ {% else %} +
{% trans "No price checks yet." %}
+ {% endif %} +
+ +
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/templates/pricemon/settings.html b/templates/pricemon/settings.html new file mode 100644 index 0000000..1f9a6e2 --- /dev/null +++ b/templates/pricemon/settings.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Price Monitor — Settings" %}{% endblock %} + +{% block content %} +
+ +
+ + + + + +

{% trans "Price Monitor Settings" %}

+
+ + {% if messages %} + {% for msg in messages %} +
+ {{ msg }} +
+ {% endfor %} + {% endif %} + +
+

{% trans "Telegram Notifications" %}

+ +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} + {% for error in field.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endfor %} + +
+ + + {% trans "Send Test Message" %} + +
+
+
+ +

+ {% trans "These credentials apply to all price drop alerts. Create a bot via" %} + @BotFather + {% trans "and get your chat ID from" %} + @userinfobot. +

+ +
+{% endblock %} diff --git a/templates/pricemon/watcher_form.html b/templates/pricemon/watcher_form.html new file mode 100644 index 0000000..2bc130c --- /dev/null +++ b/templates/pricemon/watcher_form.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+ +
+ + + + + +

{{ title }}

+
+ + {% if messages %} + {% for msg in messages %} +
+ {{ msg }} +
+ {% endfor %} + {% endif %} + +
+
+ {% csrf_token %} + + {% for field in form %} +
+ + + {% if field.html_name == 'css_selector' %} + {# CSS selector field gets an inline Test button #} +
+
{{ field }}
+ +
+
+ {% else %} + {{ field }} + {% endif %} + + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} + {% for error in field.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endfor %} + +
+ + + {% trans "Cancel" %} + +
+
+
+ +
+

{% trans "Selector tip:" %} inspect the price on the product page and find its CSS class. E.g. .product-price or span[itemprop="price"]

+
+ +
+ +{% block extra_js %} + +{% endblock %} +{% endblock %}