diff --git a/apps/constants.py b/apps/constants.py index 74d5486bc4..c70a99ba8a 100644 --- a/apps/constants.py +++ b/apps/constants.py @@ -52,6 +52,23 @@ ("superhero", "superhero"), ) +BULMA_COLOR_EMPTY = "" +BULMA_COLOR_PRIMARY = "is-primary" +BULMA_COLOR_LINK = "is-link" +BULMA_COLOR_INFO = "is-info" +BULMA_COLOR_DANGER = "is-danger" +BULMA_COLOR_WARNING = "is-warning" +BULMA_COLOR_SUCCESS = "is-success" +BULMA_COLOR_CHOICES = ( + (BULMA_COLOR_EMPTY, "empty"), + (BULMA_COLOR_INFO, "is-info"), + (BULMA_COLOR_LINK, "is-link"), + (BULMA_COLOR_PRIMARY, "is-primary"), + (BULMA_COLOR_DANGER, "is-danger"), + (BULMA_COLOR_WARNING, "is-warning"), + (BULMA_COLOR_SUCCESS, "is-success"), +) + # 判断节点在线时间间隔 NODE_TIME_OUT = 75 diff --git a/apps/proxy/admin.py b/apps/proxy/admin.py index 0b8eafd8d0..2c59d5a1b2 100644 --- a/apps/proxy/admin.py +++ b/apps/proxy/admin.py @@ -30,6 +30,9 @@ class OccupancyConfigInline(admin.StackedInline): "occupancy_price", "occupancy_traffic", "occupancy_user_limit", + "color", + "status", + "remark", ] def get_formset(self, request, obj=None, **kwargs): @@ -236,6 +239,15 @@ def traffic_info(self, instance): def out_of_usage(self, instance): return instance.out_of_usage() + def get_form(self, request, obj=None, **kwargs): + if obj: + help_texts = { + "total_traffic": f"={traffic_format(obj.total_traffic)}", + "used_traffic": f"={traffic_format(obj.used_traffic)}", + } + kwargs.update({"help_texts": help_texts}) + return super().get_form(request, obj, **kwargs) + # Register your models here. admin.site.register(models.ProxyNode, ProxyNodeAdmin) diff --git a/apps/proxy/migrations/0024_occupancyconfig_color_occupancyconfig_remark_and_more.py b/apps/proxy/migrations/0024_occupancyconfig_color_occupancyconfig_remark_and_more.py new file mode 100644 index 0000000000..687674d5f9 --- /dev/null +++ b/apps/proxy/migrations/0024_occupancyconfig_color_occupancyconfig_remark_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.6 on 2023-12-22 06:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0023_alter_userproxynodeoccupancy_index_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="occupancyconfig", + name="color", + field=models.CharField( + blank=True, + choices=[ + ("", "empty"), + ("is-info", "is-info"), + ("is-link", "is-link"), + ("is-primary", "is-primary"), + ("is-danger", "is-danger"), + ("is-warning", "is-warning"), + ("is-uccess", "is-success"), + ], + default="", + max_length=32, + verbose_name="颜色", + ), + ), + migrations.AddField( + model_name="occupancyconfig", + name="remark", + field=models.CharField( + blank=True, default="", max_length=64, verbose_name="备注" + ), + ), + migrations.AddField( + model_name="occupancyconfig", + name="status", + field=models.CharField( + choices=[("active", "active"), ("normal", "normal")], + default="normal", + max_length=32, + verbose_name="状态", + ), + ), + ] diff --git a/apps/proxy/models.py b/apps/proxy/models.py index ada8f76558..82b36c99a7 100644 --- a/apps/proxy/models.py +++ b/apps/proxy/models.py @@ -3,6 +3,7 @@ import random from collections import defaultdict from copy import deepcopy +from datetime import timedelta from decimal import Decimal from functools import cached_property from urllib.parse import quote, urlencode @@ -836,6 +837,12 @@ def total_traffic(self): class OccupancyConfig(BaseModel): + STATUS_ACTIVE = "active" + STATUS_NORMAL = "normal" + STATUS_CHOICES = ( + (STATUS_ACTIVE, "active"), + (STATUS_NORMAL, "normal"), + ) proxy_node = models.OneToOneField( ProxyNode, on_delete=models.CASCADE, @@ -848,6 +855,17 @@ class OccupancyConfig(BaseModel): ) occupancy_traffic = models.BigIntegerField(default=0, verbose_name="流量(单位字节)") occupancy_user_limit = models.PositiveIntegerField(verbose_name="用户数", default=0) + color = models.CharField( + choices=c.BULMA_COLOR_CHOICES, + max_length=32, + default=c.BULMA_COLOR_EMPTY, + blank=True, + verbose_name="颜色", + ) + status = models.CharField( + "状态", max_length=32, choices=STATUS_CHOICES, default=STATUS_NORMAL + ) + remark = models.CharField("备注", max_length=64, blank=True, default="") class Meta: verbose_name = "占用配置" @@ -857,16 +875,47 @@ def __str__(self) -> str: return f"占用配置:{self.id}" @classmethod - def get_by_proxy_node(cls, node: ProxyNode): - return cls.objects.filter(proxy_node=node).first() + def get_purchasable_proxy_nodes(cls, user: User): + # 1. get all proxy nodes that have occupancy config + query = cls.objects.filter(occupancy_user_limit__gt=0) + # 2. filter out nodes that has been occupied by other users + occupied_node_ids = UserProxyNodeOccupancy.get_occupied_node_ids() + not_occupied_node_ids = query.exclude( + proxy_node_id__in=occupied_node_ids + ).values("proxy_node_id") + # 3. add nodes that has been occupied by user but not exceed limit + not_reach_limit_node_ids = [] + for node_query in occupied_node_ids: + node_id = node_query["proxy_node_id"] + cfg = cls.objects.get(proxy_node_id=node_id) + if not cfg.reach_limit(user): + not_reach_limit_node_ids.append(node_id) + return ProxyNode.objects.filter(id__in=not_occupied_node_ids).select_related( + "occupancy_config" + ) | ProxyNode.objects.filter(id__in=not_reach_limit_node_ids).select_related( + "occupancy_config" + ) - def to_snapshot(self): - return { - "proxy_node_id": self.proxy_node.id, - "occupancy_price": self.occupancy_price, - "occupancy_traffic": self.occupancy_traffic, - "occupancy_user_limit": self.occupancy_user_limit, - } + def reach_limit(self, user: User): + node = self.proxy_node + # 1. check if node has been occupied by user, if yes, return False because user can occupy same node multiple times + node_user_ids = [ + i["user_id"] + for i in UserProxyNodeOccupancy.get_node_occupancy_user_ids(node) + ] + if user.id in node_user_ids: + return False + else: + return len(node_user_ids) >= self.occupancy_user_limit + + @property + def human_occupancy_traffic(self): + return utils.traffic_format(self.occupancy_traffic) + + @property + def bulma_is_active(self): + if self.status == self.STATUS_ACTIVE: + return "is-active" class UserProxyNodeOccupancy(BaseModel): @@ -899,15 +948,14 @@ def _valid_occupancy_query(cls): @classmethod @transaction.atomic - def create_occupancy( - cls, user: User, proxy_node: ProxyNode, occupancy_config: OccupancyConfig - ): + def create_occupancy(cls, user: User, node: ProxyNode): + occupancy_config = node.occupancy_config # check user limit first if occupancy_config.occupancy_user_limit <= 0: raise Exception("not allow to create occupancy record with user limit 0") if occupancy_config.occupancy_user_limit > 0: if ( - cls.get_node_occupancies(proxy_node).count() + cls.get_node_occupancies(node).count() >= occupancy_config.occupancy_user_limit ): raise Exception("occupancy user limit exceed") @@ -916,8 +964,10 @@ def create_occupancy( if user.balance < occupancy_config.occupancy_price: raise Exception("user balance not enough") + user.balance -= occupancy_config.occupancy_price + user.save() # check if user already occupied this node - o = cls.objects.filter(user=user, proxy_node=proxy_node).first() + o = cls.objects.filter(user=user, proxy_node=node).first() if o: if o.out_of_usage(): # reset traffic and time when out of usage @@ -928,13 +978,13 @@ def create_occupancy( o.save() else: # incr traffic and time - o.end_time = o.end_time.add(days=30) + o.end_time = o.end_time + timedelta(days=30) o.total_traffic += occupancy_config.occupancy_traffic o.save() else: return cls.objects.create( user=user, - proxy_node=proxy_node, + proxy_node=node, start_time=utils.get_current_datetime(), end_time=utils.get_current_datetime().add(days=30), total_traffic=occupancy_config.occupancy_traffic, @@ -964,6 +1014,33 @@ def check_and_incr_traffic(cls, user_id, proxy_node_id, traffic): r.used_traffic += traffic r.save() + @classmethod + def get_user_occupancies(cls, user: User): + return cls._valid_occupancy_query().filter(user=user) + + def human_total_traffic(self): + return utils.traffic_format(self.total_traffic) + + def human_used_traffic(self): + return utils.traffic_format(self.used_traffic) + + def used_percentage(self): + return round(self.used_traffic / self.total_traffic, 2) * 100 + + @property + def progress_color(self): + percentage = self.used_percentage() + if percentage < 20: + return c.BULMA_COLOR_SUCCESS + elif percentage < 40: + return c.BULMA_COLOR_INFO + elif percentage < 60: + return c.BULMA_COLOR_LINK + elif percentage < 80: + return c.BULMA_COLOR_WARNING + else: + return c.BULMA_COLOR_DANGER + def out_of_usage(self): return ( self.used_traffic >= self.total_traffic diff --git a/apps/sspanel/urls.py b/apps/sspanel/urls.py index d03d6c5dae..d0f7c88046 100644 --- a/apps/sspanel/urls.py +++ b/apps/sspanel/urls.py @@ -22,6 +22,10 @@ # 捐赠/充值 path("shop/", views.ShopView.as_view(), name="shop"), path("chargecenter/", views.ChargeView.as_view(), name="chargecenter"), + # 独享节点 + path( + "node_occupancy/", views.ProxyNodeOccupancyView.as_view(), name="node_occupancy" + ), # 公告 path("announcement/", views.AnnouncementView.as_view(), name="announcement"), # 工单 diff --git a/apps/sspanel/views.py b/apps/sspanel/views.py index 52c499e767..a1938c6712 100644 --- a/apps/sspanel/views.py +++ b/apps/sspanel/views.py @@ -14,7 +14,7 @@ ) from apps.constants import THEME_CHOICES -from apps.proxy.models import ProxyNode +from apps.proxy.models import OccupancyConfig, ProxyNode, UserProxyNodeOccupancy from apps.sspanel.forms import LoginForm, RegisterForm, TGLoginForm from apps.sspanel.models import ( Announcement, @@ -344,3 +344,32 @@ def get(self, request, pk): else: messages.error(request, "该工单不存在", extra_tags="删除失败") return HttpResponseRedirect(reverse("sspanel:tickets")) + + +class ProxyNodeOccupancyView(LoginRequiredMixin, View): + def get(self, request): + purchasable_proxy_nodes = OccupancyConfig.get_purchasable_proxy_nodes( + request.user + ) + occupies = UserProxyNodeOccupancy.get_user_occupancies(request.user) + context = { + "user": request.user, + "purchasable_proxy_nodes": purchasable_proxy_nodes, + "occupies": occupies, + } + return render(request, "web/node_occupancy.html", context=context) + + def post(self, request): + node_id = request.POST.get("node_id") + node = ProxyNode.get_by_id(node_id) + if not node: + messages.error(request, "节点不存在", extra_tags="购买失败") + return HttpResponseRedirect(reverse("sspanel:node_occupancy")) + try: + UserProxyNodeOccupancy.create_occupancy(user=request.user, node=node) + except Exception as e: + messages.error(request, str(e), extra_tags="购买失败") + return HttpResponseRedirect(reverse("sspanel:node_occupancy")) + else: + messages.success(request, "更新订阅后使用", extra_tags=f"{node.name} 购买成功!") + return HttpResponseRedirect(reverse("sspanel:node_occupancy")) diff --git a/templates/web/node_occupancy.html b/templates/web/node_occupancy.html new file mode 100644 index 0000000000..a976b00aeb --- /dev/null +++ b/templates/web/node_occupancy.html @@ -0,0 +1,106 @@ +{% extends 'base.html' %} + +{% block main %} + +
+
+
+
+

+ 独享节点 +

+

+ 受够了和别人共享节点?来买一个独享节点吧 :) +

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

可购买

+
+ {% for node in purchasable_proxy_nodes %} +
+
{{ node.name }}
+
+ {{node.occupancy_config.occupancy_price}}/月 +
+
+
类型: + {{ node.node_type }} +
+
地区:{{ node.country }} + + + +
+
月流量:{{ node.occupancy_config.human_occupancy_traffic }}
+
备注:{{ node.occupancy_config.remark }}
+
+
+ {% csrf_token %} + + +
+
+ {% empty %} +

被富哥买完啦,请发工单联系站长补货...

+ {% endfor %} +
+
+ + +
+

已购买

+ + + + + + + + + + + + + {% for o in occupies %} + + + + + + + + + {% endfor %} + +
节点开始时间到期时间已用流量总流量进度
{{ o.proxy_node.name }}{{ o.start_time }}{{ o.end_time }}{{ o.human_used_traffic }}{{ o.human_total_traffic }} + {{ o.used_percentage }}% +
+
+
+ +{% endblock main %} \ No newline at end of file