django-spire 0.23.4__py3-none-any.whl → 0.23.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
File without changes
django_spire/consts.py CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.23.4'
1
+ __VERSION__ = '0.23.5'
2
2
 
3
3
  MAINTENANCE_MODE_SETTINGS_NAME = 'MAINTENANCE_MODE'
4
4
 
@@ -129,7 +129,7 @@ def infinite_scrolling_view(
129
129
  if context_data is None:
130
130
  context_data = {}
131
131
 
132
- default_batch_size = 25
132
+ default_batch_size = 50
133
133
 
134
134
  page = int(request.GET.get('page', 1))
135
135
 
@@ -1,10 +1,21 @@
1
+ from django_spire.contrib.progress.enums import ProgressStatus
1
2
  from django_spire.contrib.progress.mixins import ProgressTrackingMixin
3
+ from django_spire.contrib.progress.runner import TaskProgressUpdater
4
+ from django_spire.contrib.progress.states import TaskState, TrackerState
5
+ from django_spire.contrib.progress.task import ProgressMessages, Task, TaskResult
2
6
  from django_spire.contrib.progress.tracker import ProgressTracker
3
7
  from django_spire.contrib.progress.views import sse_stream_view
4
8
 
5
9
 
6
10
  __all__ = [
11
+ 'ProgressMessages',
12
+ 'ProgressStatus',
7
13
  'ProgressTracker',
8
14
  'ProgressTrackingMixin',
15
+ 'Task',
16
+ 'TaskProgressUpdater',
17
+ 'TaskResult',
18
+ 'TaskState',
19
+ 'TrackerState',
9
20
  'sse_stream_view',
10
21
  ]
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class ProgressStatus(StrEnum):
7
+ COMPLETE = 'complete'
8
+ ERROR = 'error'
9
+ PENDING = 'pending'
10
+ PROCESSING = 'processing'
@@ -7,6 +7,8 @@ from django_spire.contrib.progress.tracker import ProgressTracker
7
7
  if TYPE_CHECKING:
8
8
  from typing import Any
9
9
 
10
+ from django_spire.contrib.progress.enums import ProgressStatus
11
+
10
12
 
11
13
  class ProgressTrackingMixin:
12
14
  _tracker: ProgressTracker | None = None
@@ -17,19 +19,18 @@ class ProgressTrackingMixin:
17
19
  @property
18
20
  def tracker(self) -> ProgressTracker:
19
21
  if self._tracker is None:
20
- key = self.get_tracker_key()
21
- self._tracker = ProgressTracker(key)
22
+ self._tracker = ProgressTracker(self.get_tracker_key())
22
23
 
23
24
  return self._tracker
24
25
 
26
+ def progress_error(self, message: str) -> None:
27
+ self.tracker.error(message)
28
+
25
29
  def update_progress(
26
30
  self,
27
- step: str,
31
+ status: ProgressStatus,
28
32
  message: str,
29
33
  progress: int,
30
34
  **kwargs: Any
31
35
  ) -> None:
32
- self.tracker.update(step, message, progress, **kwargs)
33
-
34
- def progress_error(self, message: str) -> None:
35
- self.tracker.error(message)
36
+ self.tracker.update(status, message, progress, **kwargs)
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+ import threading
6
+ import time
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django_spire.contrib.progress.enums import ProgressStatus
11
+ from django_spire.contrib.progress.task import ProgressMessages, Task, TaskResult
12
+
13
+ if TYPE_CHECKING:
14
+ from typing import Any, Callable
15
+
16
+ from django_spire.contrib.progress.tracker import ProgressTracker
17
+
18
+
19
+ class TaskProgressUpdater:
20
+ def __init__(self, tracker: ProgressTracker, task: Task) -> None:
21
+ self._messages = ProgressMessages()
22
+ self._task = task
23
+ self._tracker = tracker
24
+
25
+ def complete(self, message: str | None = None) -> None:
26
+ self._tracker._update_task(
27
+ self._task.name,
28
+ ProgressStatus.COMPLETE,
29
+ self._format(message or self._messages.complete),
30
+ 100
31
+ )
32
+
33
+ def error(self, message: str) -> None:
34
+ self._tracker._update_task(
35
+ self._task.name,
36
+ ProgressStatus.ERROR,
37
+ self._format(message),
38
+ 0
39
+ )
40
+
41
+ def start(self) -> None:
42
+ self._tracker._update_task(
43
+ self._task.name,
44
+ ProgressStatus.PROCESSING,
45
+ self._format(self._messages.starting),
46
+ 2
47
+ )
48
+
49
+ def update(self, message: str, progress: int) -> None:
50
+ self._tracker._update_task(
51
+ self._task.name,
52
+ ProgressStatus.PROCESSING,
53
+ self._format(message),
54
+ progress
55
+ )
56
+
57
+ def _format(self, message: str) -> str:
58
+ return f'{self._task.label}: {message}'
59
+
60
+
61
+ class ProgressSimulator:
62
+ def __init__(
63
+ self,
64
+ updater: TaskProgressUpdater,
65
+ max_progress: int = 90,
66
+ update_interval: float = 0.15
67
+ ) -> None:
68
+ self._max_progress = max_progress
69
+ self._messages = ProgressMessages()
70
+ self._update_interval = update_interval
71
+ self._updater = updater
72
+
73
+ def run(self, stop_event: threading.Event) -> None:
74
+ start_time = time.time()
75
+ duration = random.uniform(8.0, 15.0)
76
+
77
+ while not stop_event.is_set():
78
+ elapsed = time.time() - start_time
79
+ t = min(elapsed / duration, 1.0)
80
+
81
+ progress = self._ease_out_expo(t) * self._max_progress
82
+ progress = min(int(progress), self._max_progress)
83
+
84
+ jitter = random.uniform(-1.5, 1.5) if progress < self._max_progress - 5 else 0
85
+ progress = max(2, min(int(progress + jitter), self._max_progress))
86
+
87
+ message = self._get_message_for_progress(progress)
88
+ self._updater.update(message, progress)
89
+
90
+ if progress >= self._max_progress:
91
+ break
92
+
93
+ time.sleep(self._update_interval + random.uniform(0, 0.1))
94
+
95
+ def _ease_out_expo(self, t: float) -> float:
96
+ if t >= 1.0:
97
+ return 1.0
98
+
99
+ return 1.0 - math.pow(2, -10 * t)
100
+
101
+ def _get_message_for_progress(self, progress: int) -> str:
102
+ steps = self._messages.steps
103
+ index = min(int(progress / (self._max_progress / len(steps))), len(steps) - 1)
104
+
105
+ return steps[index]
106
+
107
+
108
+ class TaskRunner:
109
+ def __init__(self, tracker: ProgressTracker, task: Task) -> None:
110
+ self._stop_event = threading.Event()
111
+ self._task = task
112
+ self._tracker = tracker
113
+ self._updater = TaskProgressUpdater(tracker, task)
114
+
115
+ def run_parallel(self, results: dict[str, TaskResult]) -> None:
116
+ self._execute(results, lambda: self._task.func())
117
+
118
+ def run_sequential(self, results: dict[str, TaskResult]) -> None:
119
+ unwrapped = {name: result.value for name, result in results.items()}
120
+ self._execute(results, lambda: self._task.func(unwrapped))
121
+
122
+ def stop(self) -> None:
123
+ self._stop_event.set()
124
+
125
+ def _execute(self, results: dict[str, TaskResult], executor: Callable[[], Any]) -> None:
126
+ simulator = ProgressSimulator(self._updater)
127
+ progress_thread = threading.Thread(target=simulator.run, args=(self._stop_event,))
128
+ progress_thread.start()
129
+
130
+ try:
131
+ self._updater.start()
132
+ value = executor()
133
+ results[self._task.name] = TaskResult(value=value)
134
+ self._updater.complete()
135
+ except BaseException as e:
136
+ results[self._task.name] = TaskResult(error=e)
137
+ self._updater.error(str(e))
138
+ finally:
139
+ self._stop_event.set()
140
+ progress_thread.join(timeout=1)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ from django_spire.contrib.progress.enums import ProgressStatus
7
+
8
+ if TYPE_CHECKING:
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class TaskState:
14
+ message: str = 'Waiting...'
15
+ progress: int = 0
16
+ status: ProgressStatus = ProgressStatus.PENDING
17
+
18
+ @classmethod
19
+ def from_dict(cls, data: dict[str, Any]) -> TaskState:
20
+ return cls(
21
+ message=data.get('message', 'Waiting...'),
22
+ progress=data.get('progress', 0),
23
+ status=ProgressStatus(data.get('step', ProgressStatus.PENDING)),
24
+ )
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {
28
+ 'message': self.message,
29
+ 'progress': self.progress,
30
+ 'step': self.status.value,
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class TrackerState:
36
+ message: str = 'Initializing...'
37
+ progress: int = 0
38
+ status: ProgressStatus = ProgressStatus.PENDING
39
+ task_order: list[str] = field(default_factory=list)
40
+ tasks: dict[str, TaskState] = field(default_factory=dict)
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: dict[str, Any]) -> TrackerState:
44
+ tasks = {
45
+ name: TaskState.from_dict(task_data)
46
+ for name, task_data in data.get('tasks', {}).items()
47
+ }
48
+
49
+ return cls(
50
+ message=data.get('message', 'Initializing...'),
51
+ progress=data.get('progress', 0),
52
+ status=ProgressStatus(data.get('step', ProgressStatus.PENDING)),
53
+ task_order=data.get('task_order', []),
54
+ tasks=tasks,
55
+ )
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ 'message': self.message,
60
+ 'progress': self.progress,
61
+ 'step': self.status.value,
62
+ 'task_order': self.task_order,
63
+ 'tasks': {name: task.to_dict() for name, task in self.tasks.items()},
64
+ }
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from typing import Any, Callable
8
+
9
+
10
+ @dataclass
11
+ class ProgressMessages:
12
+ complete: str = 'Complete'
13
+ error: str = 'Error'
14
+ starting: str = 'Starting...'
15
+ steps: list[str] = field(default_factory=lambda: [
16
+ 'Initializing...',
17
+ 'Processing...',
18
+ 'Analyzing...',
19
+ 'Working...',
20
+ 'Almost there...',
21
+ 'Finalizing...',
22
+ ])
23
+
24
+
25
+ @dataclass
26
+ class Task:
27
+ func: Callable
28
+ label: str
29
+ name: str
30
+ parallel: bool = False
31
+
32
+
33
+ @dataclass
34
+ class TaskResult:
35
+ error: BaseException | None = None
36
+ value: Any = None
37
+
38
+ @property
39
+ def failed(self) -> bool:
40
+ return self.error is not None
@@ -14,21 +14,28 @@
14
14
  data-redirect-url="{% block progress_redirect_url %}{% endblock %}"
15
15
  data-redirect-delay="{% block progress_redirect_delay %}1000{% endblock %}"
16
16
  x-data="{
17
- step: 'starting',
17
+ tasks: {},
18
+ task_order: [],
19
+ overall_progress: 0,
18
20
  message: 'Initializing...',
19
- progress: 0,
20
21
  has_error: false,
21
22
  stream: null,
23
+
22
24
  init() {
23
- const stream_url = this.$el.dataset.streamUrl;
24
- const redirect_url = this.$el.dataset.redirectUrl;
25
- const redirect_delay = parseInt(this.$el.dataset.redirectDelay);
25
+ let stream_url = this.$el.dataset.streamUrl;
26
+ let redirect_url = this.$el.dataset.redirectUrl;
27
+ let redirect_delay = parseInt(this.$el.dataset.redirectDelay);
26
28
 
27
29
  this.stream = new ProgressStream(stream_url, {
28
30
  on_update: (data) => {
29
- this.step = data.step;
30
31
  this.message = data.message;
31
- this.progress = data.progress;
32
+ this.overall_progress = data.progress;
33
+ if (data.task_order) {
34
+ this.task_order = data.task_order;
35
+ }
36
+ if (data.tasks) {
37
+ this.tasks = data.tasks;
38
+ }
32
39
  },
33
40
  on_complete: (data) => {
34
41
  setTimeout(() => {
@@ -43,30 +50,57 @@
43
50
  redirect_delay: redirect_delay
44
51
  });
45
52
  this.stream.start();
53
+ },
54
+
55
+ format_task_name(name) {
56
+ return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
57
+ },
58
+
59
+ get_progress_class(step) {
60
+ if (step === 'complete') return 'bg-success';
61
+ if (step === 'error') return 'bg-danger';
62
+ if (step === 'processing') return 'bg-app-accent';
63
+ return 'bg-secondary';
46
64
  }
47
65
  }"
48
66
  >
49
67
  <div class="row g-3">
50
68
  <div class="col-12" x-show="!has_error">
51
- <div class="d-flex justify-content-between align-items-center mb-2">
52
- <span class="fs-6" x-text="message"></span>
53
- {% include 'django_spire/badge/accent_badge.html' with x_badge_text="progress + '%'" %}
54
- </div>
55
- <div class="progress" style="height: 8px;">
56
- <div
57
- class="progress-bar bg-app-accent"
58
- role="progressbar"
59
- :style="'width: ' + progress + '%'"
60
- :aria-valuenow="progress"
61
- aria-valuemin="0"
62
- aria-valuemax="100"
63
- ></div>
69
+ <template x-for="name in task_order" :key="name">
70
+ <div class="mb-3" x-show="tasks[name]">
71
+ <div class="d-flex justify-content-between align-items-center mb-1">
72
+ <span class="fs-7" x-text="format_task_name(name)"></span>
73
+ <span class="fs-7" x-text="tasks[name]?.progress + '%'"></span>
74
+ </div>
75
+ <div class="progress" style="height: 6px;">
76
+ <div
77
+ class="progress-bar"
78
+ :class="get_progress_class(tasks[name]?.step)"
79
+ role="progressbar"
80
+ :style="'width: ' + (tasks[name]?.progress || 0) + '%'"
81
+ ></div>
82
+ </div>
83
+ </div>
84
+ </template>
85
+
86
+ <div class="mt-4 pt-3 border-top">
87
+ <div class="d-flex justify-content-between align-items-center mb-2">
88
+ <span class="fs-6 fw-medium">Overall Progress</span>
89
+ <span class="fs-6" x-text="overall_progress + '%'"></span>
90
+ </div>
91
+ <div class="progress" style="height: 8px;">
92
+ <div
93
+ class="progress-bar bg-app-accent"
94
+ role="progressbar"
95
+ :style="'width: ' + overall_progress + '%'"
96
+ ></div>
97
+ </div>
64
98
  </div>
65
99
  </div>
66
100
 
67
101
  <div class="col-12" x-show="!has_error">
68
102
  <p class="text-app-secondary mb-0 fs-7">
69
- {% block progress_wait_message %}We are processing your request. This may take a moment.{% endblock %}
103
+ {% block progress_wait_message %}Processing your request. This may take a moment.{% endblock %}
70
104
  </p>
71
105
  </div>
72
106
 
@@ -1,39 +1,245 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
4
+ import threading
5
+ import time
6
+
3
7
  from typing import TYPE_CHECKING
4
8
 
5
9
  from django.core.cache import cache
6
10
 
11
+ from django_spire.contrib.progress.enums import ProgressStatus
12
+ from django_spire.contrib.progress.runner import TaskRunner
13
+ from django_spire.contrib.progress.states import TaskState, TrackerState
14
+ from django_spire.contrib.progress.task import Task, TaskResult
15
+
7
16
  if TYPE_CHECKING:
8
- from typing import Any
17
+ from typing import Any, Callable
18
+
19
+
20
+ log = logging.getLogger(__name__)
9
21
 
10
22
 
11
23
  class ProgressTracker:
12
24
  def __init__(self, key: str, timeout: int = 300) -> None:
13
- self.key = f'progress_tracker_{key}'
14
- self.timeout = timeout
25
+ self._key = f'progress_tracker_{key}'
26
+ self._lock = threading.Lock()
27
+ self._tasks: list[Task] = []
28
+ self._timeout = timeout
29
+
30
+ def clear(self) -> None:
31
+ cache.delete(self._key)
32
+
33
+ def complete(self, message: str = 'Complete!') -> None:
34
+ with self._lock:
35
+ state = self._get_state()
36
+ state.message = message
37
+ state.progress = 100
38
+ state.status = ProgressStatus.COMPLETE
39
+ self._save_state(state)
40
+
41
+ def error(self, message: str) -> None:
42
+ with self._lock:
43
+ state = self._get_state()
44
+ state.message = message
45
+ state.progress = 0
46
+ state.status = ProgressStatus.ERROR
47
+ self._save_state(state)
48
+
49
+ def execute(self) -> dict[str, Any] | None:
50
+ self._create_state()
51
+
52
+ error = False
53
+ results: dict[str, TaskResult] = {}
54
+
55
+ try:
56
+ error = self._execute_parallel_tasks(results)
57
+
58
+ if not error:
59
+ error = self._execute_sequential_tasks(results)
60
+
61
+ if not error:
62
+ self.complete()
63
+ time.sleep(1)
64
+ except BaseException:
65
+ log.exception('Progress tracker failed')
66
+ error = True
67
+
68
+ if error:
69
+ self.error('An unexpected error occurred')
70
+ time.sleep(2)
71
+
72
+ self.clear()
73
+
74
+ if error:
75
+ return None
76
+
77
+ return {name: result.value for name, result in results.items()}
78
+
79
+ def get(self) -> dict[str, Any] | None:
80
+ return cache.get(self._key)
81
+
82
+ def parallel(
83
+ self,
84
+ name: str,
85
+ func: Callable[[], Any],
86
+ label: str | None = None
87
+ ) -> ProgressTracker:
88
+ task = Task(
89
+ func=func,
90
+ label=label or self._generate_label(name),
91
+ name=name,
92
+ parallel=True,
93
+ )
94
+
95
+ self._tasks.append(task)
96
+
97
+ return self
98
+
99
+ def sequential(
100
+ self,
101
+ name: str,
102
+ func: Callable[[dict[str, Any]], Any],
103
+ label: str | None = None
104
+ ) -> ProgressTracker:
105
+ task = Task(
106
+ func=func,
107
+ label=label or self._generate_label(name),
108
+ name=name,
109
+ parallel=False,
110
+ )
111
+
112
+ self._tasks.append(task)
113
+
114
+ return self
115
+
116
+ def start(self) -> None:
117
+ state = TrackerState()
118
+ cache.set(self._key, state.to_dict(), timeout=self._timeout)
15
119
 
16
120
  def update(
17
121
  self,
18
- step: str,
122
+ status: ProgressStatus,
19
123
  message: str,
20
124
  progress: int,
21
125
  **kwargs: Any
22
126
  ) -> None:
23
- data = {
24
- 'step': step,
25
- 'message': message,
26
- 'progress': progress,
27
- **kwargs
28
- }
127
+ with self._lock:
128
+ state = self._get_state()
129
+ state.message = message
130
+ state.progress = progress
131
+ state.status = status
132
+ self._save_state(state, **kwargs)
29
133
 
30
- cache.set(self.key, data, timeout=self.timeout)
134
+ def _calculate_overall_progress(self, state: TrackerState) -> int:
135
+ if not state.tasks:
136
+ return 0
31
137
 
32
- def get(self) -> dict[str, Any] | None:
33
- return cache.get(self.key)
138
+ total = sum(
139
+ task.progress
140
+ for task in state.tasks.values()
141
+ )
34
142
 
35
- def clear(self) -> None:
36
- cache.delete(self.key)
143
+ return int(total / len(state.tasks))
37
144
 
38
- def error(self, message: str) -> None:
39
- self.update('error', message, 0)
145
+ def _create_state(self) -> None:
146
+ task_names = [task.name for task in self._tasks]
147
+
148
+ state = TrackerState(
149
+ task_order=task_names,
150
+ tasks={name: TaskState() for name in task_names}
151
+ )
152
+
153
+ cache.set(self._key, state.to_dict(), timeout=self._timeout)
154
+
155
+ def _execute_parallel_tasks(self, results: dict[str, TaskResult]) -> bool:
156
+ tasks = [
157
+ task
158
+ for task in self._tasks if task.parallel
159
+ ]
160
+
161
+ if not tasks:
162
+ return False
163
+
164
+ runners: list[TaskRunner] = []
165
+ threads: list[threading.Thread] = []
166
+
167
+ for task in tasks:
168
+ runner = TaskRunner(self, task)
169
+ runners.append(runner)
170
+
171
+ thread = threading.Thread(target=runner.run_parallel, args=(results,))
172
+ threads.append(thread)
173
+ thread.start()
174
+
175
+ for thread in threads:
176
+ thread.join(timeout=self._timeout)
177
+
178
+ if thread.is_alive():
179
+ for runner in runners:
180
+ runner.stop()
181
+
182
+ return True
183
+
184
+ return any(
185
+ results.get(task.name, TaskResult()).failed
186
+ for task in tasks
187
+ )
188
+
189
+ def _execute_sequential_tasks(self, results: dict[str, TaskResult]) -> bool:
190
+ tasks = [
191
+ task
192
+ for task in self._tasks
193
+ if not task.parallel
194
+ ]
195
+
196
+ for task in tasks:
197
+ runner = TaskRunner(self, task)
198
+ runner.run_sequential(results)
199
+
200
+ if results.get(task.name, TaskResult()).failed:
201
+ return True
202
+
203
+ return False
204
+
205
+ def _generate_label(self, name: str) -> str:
206
+ return name.replace('_', ' ').title()
207
+
208
+ def _get_state(self) -> TrackerState:
209
+ task_names = [task.name for task in self._tasks]
210
+ data = cache.get(self._key)
211
+
212
+ if data is None:
213
+ return TrackerState(
214
+ task_order=task_names,
215
+ tasks={name: TaskState() for name in task_names}
216
+ )
217
+
218
+ return TrackerState.from_dict(data)
219
+
220
+ def _save_state(self, state: TrackerState, **kwargs: Any) -> None:
221
+ data = state.to_dict()
222
+ data.update(kwargs)
223
+ cache.set(self._key, data, timeout=self._timeout)
224
+
225
+ def _update_task(
226
+ self,
227
+ name: str,
228
+ status: ProgressStatus,
229
+ message: str,
230
+ progress: int
231
+ ) -> None:
232
+ with self._lock:
233
+ state = self._get_state()
234
+
235
+ state.tasks[name] = TaskState(
236
+ message=message,
237
+ progress=progress,
238
+ status=status,
239
+ )
240
+
241
+ state.message = message
242
+ state.progress = self._calculate_overall_progress(state)
243
+ state.status = ProgressStatus.PROCESSING
244
+
245
+ self._save_state(state)
@@ -5,46 +5,60 @@ import time
5
5
 
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ from django.core.cache import cache
8
9
  from django.http import StreamingHttpResponse
9
10
 
10
- from django_spire.contrib.progress.tracker import ProgressTracker
11
-
12
11
  if TYPE_CHECKING:
13
- from typing import Callable
12
+ from typing import Callable, Generator
14
13
 
15
14
 
16
15
  def sse_stream_view(
17
16
  key: str,
18
17
  interval: float = 0.5,
19
- should_continue: Callable[[dict], bool] | None = None
18
+ should_continue: Callable[[dict], bool] | None = None,
19
+ timeout: int = 300
20
20
  ) -> StreamingHttpResponse:
21
- def event_stream() -> None:
22
- tracker = ProgressTracker(key)
23
- previous_progress = -1
21
+ cache_key = f'progress_tracker_{key}'
22
+
23
+ def event_stream() -> Generator[str, None, None]:
24
+ previous_data = None
25
+ start_time = time.time()
26
+ last_heartbeat = time.time()
27
+
28
+ yield ': connected\n\n'
24
29
 
25
30
  while True:
26
- time.sleep(interval)
27
- data = tracker.get()
31
+ elapsed = time.time() - start_time
32
+
33
+ if elapsed > timeout:
34
+ yield f'data: {json.dumps({"step": "error", "message": "Timeout", "progress": 0})}\n\n'
35
+ break
28
36
 
29
- if data:
30
- current_progress = data.get('progress', 0)
37
+ data = cache.get(cache_key)
31
38
 
32
- if current_progress != previous_progress:
33
- yield f'data: {json.dumps(data)}\n\n'
34
- previous_progress = current_progress
39
+ if data and data != previous_data:
40
+ yield f'data: {json.dumps(data)}\n\n'
41
+ previous_data = data
42
+ last_heartbeat = time.time()
35
43
 
36
44
  if should_continue and not should_continue(data):
37
- break
45
+ break
38
46
 
39
- if current_progress >= 100 or data.get('step') == 'error':
47
+ if data.get('progress', 0) >= 100 or data.get('step') == 'error':
40
48
  break
41
49
 
50
+ if time.time() - last_heartbeat > 10:
51
+ yield ': heartbeat\n\n'
52
+ last_heartbeat = time.time()
53
+
54
+ time.sleep(interval)
55
+
42
56
  response = StreamingHttpResponse(
43
57
  event_stream(),
44
58
  content_type='text/event-stream'
45
59
  )
46
60
 
47
- response['Cache-Control'] = 'no-cache'
61
+ response['Cache-Control'] = 'no-cache, no-store'
48
62
  response['X-Accel-Buffering'] = 'no'
49
63
 
50
64
  return response
@@ -1,6 +1,6 @@
1
1
  <div
2
2
  x-data="{
3
- batch_size: parseInt('{{ batch_size|default:25 }}'),
3
+ batch_size: parseInt('{{ batch_size|default:50 }}'),
4
4
  current_page: parseInt('{{ current_page|default:0 }}'),
5
5
  endpoint: '{{ endpoint }}',
6
6
  has_next: {{ has_next|default:'true'|yesno:'true,false' }},
@@ -1,6 +1,6 @@
1
1
  <div
2
2
  x-data="{
3
- batch_size: 25,
3
+ batch_size: 50,
4
4
  current_page: 0,
5
5
  endpoint: '',
6
6
  has_next: true,
@@ -16,7 +16,7 @@
16
16
  async init() {
17
17
  this.scroll_id = $id('scroll');
18
18
  this.endpoint = this.$el.dataset.endpoint || '';
19
- this.batch_size = parseInt(this.$el.dataset.batchSize || '25');
19
+ this.batch_size = parseInt(this.$el.dataset.batchSize || '50');
20
20
 
21
21
  await this.$nextTick();
22
22
 
@@ -116,7 +116,7 @@
116
116
  this.observer.observe(trigger);
117
117
  },
118
118
  }"
119
- data-batch-size="{{ batch_size|default:25 }}"
119
+ data-batch-size="{{ batch_size|default:50 }}"
120
120
  data-endpoint="{{ endpoint }}"
121
121
  :data-scroll-id="scroll_id"
122
122
  @item-mounted.window="handle_item_mounted($event)"
@@ -3,8 +3,10 @@
3
3
  <template x-for="i in skeleton_count" :key="i">
4
4
  <tr class="skeleton-row" :style="average_row_height > 0 ? `height: ${average_row_height}px;` : ''">
5
5
  <template x-for="j in column_count" :key="j">
6
- <td class="align-middle">
7
- <div class="skeleton-box" style="height: 25px; width: 90%;"></div>
6
+ <td class="align-middle h-100">
7
+ <div class="align-items-center d-flex h-100">
8
+ <div class="skeleton-box" style="height: 20px; width: 90%;"></div>
9
+ </div>
8
10
  </td>
9
11
  </template>
10
12
  </tr>
@@ -3,8 +3,10 @@
3
3
  <template x-for="i in skeleton_count" :key="i">
4
4
  <tr class="skeleton-row" :style="average_row_height > 0 ? `height: ${average_row_height}px;` : ''">
5
5
  <template x-for="j in column_count" :key="j">
6
- <td class="align-middle">
7
- <div class="skeleton-box" style="height: 25px; width: 90%;"></div>
6
+ <td class="align-middle h-100">
7
+ <div class="align-items-center d-flex h-100">
8
+ <div class="skeleton-box" style="height: 20px; width: 90%;"></div>
9
+ </div>
8
10
  </td>
9
11
  </template>
10
12
  </tr>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-spire
3
- Version: 0.23.4
3
+ Version: 0.23.5
4
4
  Summary: A project for Django Spire
5
5
  Author-email: Brayden Carlson <braydenc@stratusadv.com>, Nathan Johnson <nathanj@stratusadv.com>
6
6
  License: Copyright (c) 2024 Stratus Advanced Technologies and Contributors.
@@ -1,6 +1,6 @@
1
1
  django_spire/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  django_spire/conf.py,sha256=c5Hs-7lk9T15254tOasiQ2ZTFLQIVJof9_QJDfm1PAI,933
3
- django_spire/consts.py,sha256=YEJYj8ZY6gQy0S_li0ImtP1bPLBNvKpug3bc-Po8-VI,171
3
+ django_spire/consts.py,sha256=YR9sX6FXBUPmKDLHD7b1mDlQp6jwxIOoAF39q4rJoiM,171
4
4
  django_spire/exceptions.py,sha256=L5ndRO5ftMmh0pHkO2z_NG3LSGZviJ-dDHNT73SzTNw,48
5
5
  django_spire/settings.py,sha256=B4GPqBGt_dmkt0Ay0j-IP-SZ6mY44m2Ap5kVSON5YLA,1005
6
6
  django_spire/urls.py,sha256=mKeZszb5U4iIGqddMb5Tt5fRC72U2wABEOi6mvOfEBU,656
@@ -168,6 +168,7 @@ django_spire/auth/group/templates/django_spire/auth/group/tab/user_list_card_tab
168
168
  django_spire/auth/group/templates/django_spire/auth/group/tab/user_list_card_tabs.html,sha256=qQRlAdB8TxfR0Z-crGGW5e5qgRx9qOCyYWfSX2EgpiM,548
169
169
  django_spire/auth/group/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
170
170
  django_spire/auth/group/templatetags/permission_tags.py,sha256=nK73ZKVhgCnIAQFpD_e4TZ_hHFt9GyPJCd1tZ_56CgY,256
171
+ django_spire/auth/group/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
171
172
  django_spire/auth/group/urls/__init__.py,sha256=efpuo6oZPZJsDS3-UjwkBOrcxKmyWk8M81OwsmZ4fJs,342
172
173
  django_spire/auth/group/urls/form_urls.py,sha256=bGofmsYfaWi7XRmaWS7zdFXgd8jNrIqNjVQVV_VdfvA,584
173
174
  django_spire/auth/group/urls/json_urls.py,sha256=uOQqYiYwtFN1KafQSW6vFzzvkMFaRB5jOjFtw21NpMM,423
@@ -198,6 +199,7 @@ django_spire/auth/permissions/consts.py,sha256=Sm7Oqq-lfOLpViar6yiZI2DPBYmNvbYBG
198
199
  django_spire/auth/permissions/decorators.py,sha256=LA169sW50UUDlnpuAJuUmRzb_nvGWrR_AsyNlKXWiBE,1502
199
200
  django_spire/auth/permissions/permissions.py,sha256=089AidHUVx0DcpGtfJ9DAN38dyUmoUuEp8cIem8O-Is,6187
200
201
  django_spire/auth/permissions/tools.py,sha256=vfoI6KNLHVSaEzo1YSdNoStna0OLaoY5Xb3UBnt-DRo,2460
202
+ django_spire/auth/permissions/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
201
203
  django_spire/auth/seeding/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
202
204
  django_spire/auth/seeding/seed.py,sha256=TDbGgJb5XADk4STs5jU7VvHb9qWFWE1hhqjqsCCgn74,92
203
205
  django_spire/auth/seeding/seeder.py,sha256=t50d-13t2oEAHG1vgQ41qXMyq0tmPQ3jjZuFlR-BZug,722
@@ -319,7 +321,7 @@ django_spire/contrib/gamification/static/django_spire/css/contrib/gamification/g
319
321
  django_spire/contrib/gamification/templates/django_spire/contrib/gamification/effect/kapow.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
320
322
  django_spire/contrib/generic_views/__init__.py,sha256=fJU5nY61oqFCK-osl1Hnr7W2FKc_ZWeFuEkADWqCKzA,245
321
323
  django_spire/contrib/generic_views/modal_views.py,sha256=nDiVW9uIqNGYQx6hbY_H1YoiXg-FEKOCFow2z0qQk4A,2041
322
- django_spire/contrib/generic_views/portal_views.py,sha256=Xb_gorC3JHqE2nik1eqdVOlUdLKmh8LOZDTO1nlgwwo,7894
324
+ django_spire/contrib/generic_views/portal_views.py,sha256=03_CUGvdAC6TKp9d_wg5TO_mykhfTJLdSSlKsjzUzkA,7894
323
325
  django_spire/contrib/help/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
324
326
  django_spire/contrib/help/apps.py,sha256=UM1IoIMFNaVaQdUfACTMfUkm541TqNhOMqlp9X47zBc,193
325
327
  django_spire/contrib/help/templates/django_spire/contrib/help/help_button.html,sha256=ikym1hVCy1OuBJnU0CWhi-aCPCrLsfP3stCs0OV3PFk,162
@@ -346,14 +348,18 @@ django_spire/contrib/pagination/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TI
346
348
  django_spire/contrib/pagination/templatetags/pagination_tags.py,sha256=xE3zTDJZDdDvWf6D7fMyg_Vi0PW37OA3aZxCjOHDp4I,1173
347
349
  django_spire/contrib/performance/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
348
350
  django_spire/contrib/performance/decorators.py,sha256=xOjXX-3FVOEWqr2TTTqdh4lkIhmHTlROevOVgXDeyt8,507
349
- django_spire/contrib/progress/__init__.py,sha256=zfB13CHEfaGLA8ILNTmePG2efwBygpitqxA9CDplc0U,292
350
- django_spire/contrib/progress/mixins.py,sha256=970sYbaZq2WAey-kAVED5hskQFXNh4WeCBIgNJLfdFU,834
351
- django_spire/contrib/progress/tracker.py,sha256=VtzcD0pxCsq736RHPXJfspfC7qNdjb74Cl6VKI7U3Iw,870
352
- django_spire/contrib/progress/views.py,sha256=bBEbTktWMlizzrdchPn6G4EEnDqc64bNH7Vt005XF2E,1268
351
+ django_spire/contrib/progress/__init__.py,sha256=YEXnnbomc32c2J7e3sXHjtLboZ-PijiZOguoVHPfoUQ,719
352
+ django_spire/contrib/progress/enums.py,sha256=nwyfKxQno_ghGGTbpK8lRO1cY7dCfbxnnsQ4cSbkXBw,194
353
+ django_spire/contrib/progress/mixins.py,sha256=V84LRtniFDLK2E5dKcSZNh-a1zTf9Ee7QXg1rDfoFHo,895
354
+ django_spire/contrib/progress/runner.py,sha256=3jSqpdk2MJ7HPNutwF43Hs81O0NlzX4E9gnVaenhXUs,4445
355
+ django_spire/contrib/progress/states.py,sha256=V7JU_nzpZN77TQFW0S51Gjil8xn8CKLKkAWDOpqzA8k,1908
356
+ django_spire/contrib/progress/task.py,sha256=4pMbzOsjRRNqPKNohh08D39DRFpC3QomTCH5IdqQS0A,780
357
+ django_spire/contrib/progress/tracker.py,sha256=xjGlEyKe5aiKW_KC8NKZ8F0BjQdFpLUqHz5Gr_C7jws,6490
358
+ django_spire/contrib/progress/views.py,sha256=CwJu-L09tTiCpsjVuqzvMEKVqp6-etLDi7L9ibmlq8U,1694
353
359
  django_spire/contrib/progress/static/django_spire/css/contrib/progress/progress.css,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
354
360
  django_spire/contrib/progress/static/django_spire/js/contrib/progress/progress.js,sha256=2Zuwiu1nWJpghzU-6fmf6s4qKLLyiZUcAJwB9-v5xWI,1701
355
361
  django_spire/contrib/progress/templates/django_spire/contrib/progress/card/card.html,sha256=2Ajv0UBJEGwc4InVe69aLe2u4K56yh2JAuvO6uH24j8,3353
356
- django_spire/contrib/progress/templates/django_spire/contrib/progress/modal/content.html,sha256=oZ7mOxzro5809wSHLe4A90PonAqcr_eVawR_xHKXIt8,3682
362
+ django_spire/contrib/progress/templates/django_spire/contrib/progress/modal/content.html,sha256=Qxer-q4GBC5h8onzno2NUlPrXluw_tO1kWlGComQ98E,5232
357
363
  django_spire/contrib/queryset/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
358
364
  django_spire/contrib/queryset/enums.py,sha256=nGUHwCVOCBfVXo-lrgyqRoEaMq0ZQAGOWnjALSFPjJU,108
359
365
  django_spire/contrib/queryset/filter_tools.py,sha256=o7OBB3Jjtyoj7aaWspkFKS2bv0BeMIigXz7DVvt6Ueo,1087
@@ -664,8 +670,8 @@ django_spire/core/templates/django_spire/forms/elements/dict_element.html,sha256
664
670
  django_spire/core/templates/django_spire/forms/elements/list_element.html,sha256=NbKavjm9qlSUnl5sao3xZILT-bW-TmIFB3Bqy0NfZkg,682
665
671
  django_spire/core/templates/django_spire/forms/elements/value_element.html,sha256=LaOR2ggmf-J_cPCVHjNoD4l_858zHCRA58T_ZB3EIkE,57
666
672
  django_spire/core/templates/django_spire/forms/widgets/json_tree_widget.html,sha256=dykf6xYRZ477i6SgcGR3TSnxDwLsu2p3FR45hEr4DF8,1489
667
- django_spire/core/templates/django_spire/infinite_scroll/base.html,sha256=x4JJgXTnWXTRz2laFHBW5jpD4D9sU-P8eAUDgEc3-UU,10875
668
- django_spire/core/templates/django_spire/infinite_scroll/scroll.html,sha256=osZx9nsCypvnZY3dva_52MoZQRkLK0kOCMnGxHvhsUw,4311
673
+ django_spire/core/templates/django_spire/infinite_scroll/base.html,sha256=--8DAhRYwAfowKWKLrnfdvh4rNTeBXyziE82fReFfnc,10875
674
+ django_spire/core/templates/django_spire/infinite_scroll/scroll.html,sha256=5new-JbLIo6D9zurYN39hy41moJDj_2obeY1v9VWk_Q,4311
669
675
  django_spire/core/templates/django_spire/infinite_scroll/element/footer.html,sha256=22yxDuYB2vKqaDaMsARStrYTYoHtPGYmK6Xl9XQdwH8,391
670
676
  django_spire/core/templates/django_spire/item/infinite_scroll_item.html,sha256=NMx3jQwtcKJiKtzbQXaXI3eWOtnJFiZhCI13tLTxSnc,1011
671
677
  django_spire/core/templates/django_spire/item/item.html,sha256=9AD6qRcYIb7q-EcmWlRUFiK8XfnmPBSS5NOsXdKEAAA,1217
@@ -711,8 +717,8 @@ django_spire/core/templates/django_spire/table/element/child_row.html,sha256=B0f
711
717
  django_spire/core/templates/django_spire/table/element/expandable_row.html,sha256=sAzzUeGishdYRUqs9t4lduxuxZaOzUYNhMfMytRLq90,1516
712
718
  django_spire/core/templates/django_spire/table/element/footer.html,sha256=fVxbDrVj4C9jIswkri7B2Dr4nEr2Lf_EPPULMU3mRs0,312
713
719
  django_spire/core/templates/django_spire/table/element/header.html,sha256=NBx9Lfn3Dav5uEb-SlyB-vhZapCzxbfc2xxmrg7Bu3c,647
714
- django_spire/core/templates/django_spire/table/element/loading_skeleton.html,sha256=b3EGdrXoFT1tku4dA_sWv8FAxck5ZZhef_9FumYZYNA,577
715
- django_spire/core/templates/django_spire/table/element/refreshing_skeleton.html,sha256=nCoDCITzPewTYJ-vPDZiSCv1qhdhT2BqMwygIdF1tYg,562
720
+ django_spire/core/templates/django_spire/table/element/loading_skeleton.html,sha256=4YIsTLGjAC6T0idBjjkeqTkfXBb7sKJAUXWVbhla6ng,688
721
+ django_spire/core/templates/django_spire/table/element/refreshing_skeleton.html,sha256=nShQpQPP00z70Kvpm3CDpXpq_J_bjpBjv1BD4aVEmPE,673
716
722
  django_spire/core/templates/django_spire/table/element/row.html,sha256=BoNZTjRs_cVG11CKK5VVocAq4p-UtB79FnhGPfSXKEo,3379
717
723
  django_spire/core/templates/django_spire/table/element/trigger.html,sha256=KixtqIshbs8V1QSupIMxTe0UYJbK2oJIsqD8fZYxAOs,66
718
724
  django_spire/core/templates/django_spire/table/item/no_data_item.html,sha256=PxTyI98edGtMxRdRzuEh1P_uOKRY_X5awrWXQsVb1jI,441
@@ -1195,8 +1201,8 @@ django_spire/theme/urls/page_urls.py,sha256=S8nkKkgbhG3XHI3uMUL-piOjXIrRkuY2UlM_
1195
1201
  django_spire/theme/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
1196
1202
  django_spire/theme/views/json_views.py,sha256=PWwVTaty0BVGbj65L5cxex6JNhc-xVAI_rEYjbJWqEM,1893
1197
1203
  django_spire/theme/views/page_views.py,sha256=WenjOa6Welpu3IMolY56ZwBjy4aK9hpbiMNuygjAl1A,3922
1198
- django_spire-0.23.4.dist-info/licenses/LICENSE.md,sha256=tlTbOtgKoy-xAQpUk9gPeh9O4oRXCOzoWdW3jJz0wnA,1091
1199
- django_spire-0.23.4.dist-info/METADATA,sha256=vm0Sw_ioG2egiqQWgQrEEhHxsmbmjQwe_2AZuX5QI6A,5127
1200
- django_spire-0.23.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
1201
- django_spire-0.23.4.dist-info/top_level.txt,sha256=xf3QV1e--ONkVpgMDQE9iqjQ1Vg4--_6C8wmO-KxPHQ,13
1202
- django_spire-0.23.4.dist-info/RECORD,,
1204
+ django_spire-0.23.5.dist-info/licenses/LICENSE.md,sha256=tlTbOtgKoy-xAQpUk9gPeh9O4oRXCOzoWdW3jJz0wnA,1091
1205
+ django_spire-0.23.5.dist-info/METADATA,sha256=NsK-z_MdElXotF4ZnWMERuHNpLZODw6_kRI_lO7bt-Q,5127
1206
+ django_spire-0.23.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
1207
+ django_spire-0.23.5.dist-info/top_level.txt,sha256=xf3QV1e--ONkVpgMDQE9iqjQ1Vg4--_6C8wmO-KxPHQ,13
1208
+ django_spire-0.23.5.dist-info/RECORD,,