codex 1.4.0a1__py3-none-any.whl → 1.4.1__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.

Potentially problematic release.


This version of codex might be problematic. Click here for more details.

Files changed (259) hide show
  1. codex/config_default.yaml +12 -4
  2. codex/db_functions.py +4 -2
  3. codex/integrity.py +17 -6
  4. codex/librarian/covers/create.py +6 -8
  5. codex/librarian/importer/aggregate_metadata.py +75 -41
  6. codex/librarian/importer/clean_metadata.py +30 -7
  7. codex/librarian/importer/create_fks.py +154 -55
  8. codex/librarian/importer/deleted.py +11 -2
  9. codex/librarian/importer/failed_imports.py +41 -5
  10. codex/librarian/importer/importerd.py +34 -11
  11. codex/librarian/importer/link_comics.py +54 -31
  12. codex/librarian/importer/moved.py +55 -11
  13. codex/librarian/importer/query_fks.py +210 -48
  14. codex/librarian/importer/tasks.py +7 -7
  15. codex/librarian/janitor/cleanup.py +17 -5
  16. codex/librarian/librariand.py +10 -0
  17. codex/librarian/watchdog/events.py +11 -14
  18. codex/librarian/watchdog/observers.py +5 -1
  19. codex/logger/loggerd.py +7 -3
  20. codex/logger/logging.py +1 -1
  21. codex/migrations/0024_comic_gtin_comic_story_arc_number.py +24 -0
  22. codex/migrations/0025_add_story_arc_number.py +83 -0
  23. codex/models.py +21 -11
  24. codex/search/backend.py +1 -1
  25. codex/search/indexes.py +1 -1
  26. codex/serializers/browser.py +1 -0
  27. codex/serializers/metadata.py +5 -1
  28. codex/serializers/models.py +16 -1
  29. codex/serializers/opds/v1.py +1 -0
  30. codex/serializers/opds/v2.py +5 -2
  31. codex/serializers/reader.py +55 -16
  32. codex/settings/settings.py +1 -1
  33. codex/static_root/assets/admin-12749881.ef0f50bac290.js +41 -0
  34. codex/static_root/assets/admin-12749881.ef0f50bac290.js.br +0 -0
  35. codex/static_root/assets/admin-12749881.ef0f50bac290.js.gz +0 -0
  36. codex/static_root/assets/admin-12749881.js +41 -0
  37. codex/static_root/assets/admin-12749881.js.br +0 -0
  38. codex/static_root/assets/admin-12749881.js.gz +0 -0
  39. codex/static_root/assets/admin-beda768d.a614eee46307.css +1 -0
  40. codex/static_root/assets/admin-beda768d.a614eee46307.css.br +0 -0
  41. codex/static_root/assets/admin-beda768d.a614eee46307.css.gz +0 -0
  42. codex/static_root/assets/admin-beda768d.css +1 -0
  43. codex/static_root/assets/admin-beda768d.css.br +0 -0
  44. codex/static_root/assets/admin-beda768d.css.gz +0 -0
  45. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css +1 -0
  46. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css.br +0 -0
  47. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css.gz +0 -0
  48. codex/static_root/assets/admin-drawer-panel-41c225cc.css +1 -0
  49. codex/static_root/assets/admin-drawer-panel-41c225cc.css.br +0 -0
  50. codex/static_root/assets/admin-drawer-panel-41c225cc.css.gz +0 -0
  51. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js +1 -0
  52. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js.br +0 -0
  53. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js.gz +0 -0
  54. codex/static_root/assets/admin-drawer-panel-522f1e6c.js +1 -0
  55. codex/static_root/assets/admin-drawer-panel-522f1e6c.js.br +0 -0
  56. codex/static_root/assets/admin-drawer-panel-522f1e6c.js.gz +0 -0
  57. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css +1 -0
  58. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css.br +0 -0
  59. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css.gz +0 -0
  60. codex/static_root/assets/browser-7f7d7134.css +1 -0
  61. codex/static_root/assets/browser-7f7d7134.css.br +0 -0
  62. codex/static_root/assets/browser-7f7d7134.css.gz +0 -0
  63. codex/static_root/assets/browser-af622672.d51aca96d64d.js +1 -0
  64. codex/static_root/assets/browser-af622672.d51aca96d64d.js.br +0 -0
  65. codex/static_root/assets/browser-af622672.d51aca96d64d.js.gz +0 -0
  66. codex/static_root/assets/browser-af622672.js +1 -0
  67. codex/static_root/assets/browser-af622672.js.br +0 -0
  68. codex/static_root/assets/browser-af622672.js.gz +0 -0
  69. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js +1 -0
  70. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js.br +0 -0
  71. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js.gz +0 -0
  72. codex/static_root/assets/http-error-5e17b794.js +1 -0
  73. codex/static_root/assets/http-error-5e17b794.js.br +0 -0
  74. codex/static_root/assets/http-error-5e17b794.js.gz +0 -0
  75. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js +1 -0
  76. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js.br +0 -0
  77. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js.gz +0 -0
  78. codex/static_root/assets/main-9e76a4c3.js +1 -0
  79. codex/static_root/assets/main-9e76a4c3.js.br +0 -0
  80. codex/static_root/assets/main-9e76a4c3.js.gz +0 -0
  81. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js +1 -0
  82. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js.br +0 -0
  83. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js.gz +0 -0
  84. codex/static_root/assets/metadata-dialog-62c29ce0.js +1 -0
  85. codex/static_root/assets/metadata-dialog-62c29ce0.js.br +0 -0
  86. codex/static_root/assets/metadata-dialog-62c29ce0.js.gz +0 -0
  87. codex/static_root/assets/{metadata-dialog-785c4cfc.694a251cda37.css → metadata-dialog-cb306ffd.cc304996d7bb.css} +1 -1
  88. codex/static_root/assets/metadata-dialog-cb306ffd.cc304996d7bb.css.br +0 -0
  89. codex/static_root/assets/metadata-dialog-cb306ffd.cc304996d7bb.css.gz +0 -0
  90. codex/static_root/assets/{metadata-dialog-785c4cfc.css → metadata-dialog-cb306ffd.css} +1 -1
  91. codex/static_root/assets/metadata-dialog-cb306ffd.css.br +0 -0
  92. codex/static_root/assets/metadata-dialog-cb306ffd.css.gz +0 -0
  93. codex/static_root/assets/{page-pdf-c603e996.ab2d147c9ae1.js → page-pdf-157ba97e.613d7c2beb77.js} +61 -51
  94. codex/static_root/assets/page-pdf-157ba97e.613d7c2beb77.js.br +0 -0
  95. codex/static_root/assets/page-pdf-157ba97e.613d7c2beb77.js.gz +0 -0
  96. codex/static_root/assets/{page-pdf-c603e996.js → page-pdf-157ba97e.js} +61 -51
  97. codex/static_root/assets/page-pdf-157ba97e.js.br +0 -0
  98. codex/static_root/assets/page-pdf-157ba97e.js.gz +0 -0
  99. codex/static_root/assets/reader-36266549.0b2cf1291f27.js +1 -0
  100. codex/static_root/assets/reader-36266549.0b2cf1291f27.js.br +0 -0
  101. codex/static_root/assets/reader-36266549.0b2cf1291f27.js.gz +0 -0
  102. codex/static_root/assets/reader-36266549.js +1 -0
  103. codex/static_root/assets/reader-36266549.js.br +0 -0
  104. codex/static_root/assets/reader-36266549.js.gz +0 -0
  105. codex/static_root/assets/reader-7f004141.506eecc6954b.css +1 -0
  106. codex/static_root/assets/reader-7f004141.506eecc6954b.css.br +0 -0
  107. codex/static_root/assets/reader-7f004141.506eecc6954b.css.gz +0 -0
  108. codex/static_root/assets/reader-7f004141.css +1 -0
  109. codex/static_root/assets/reader-7f004141.css.br +0 -0
  110. codex/static_root/assets/reader-7f004141.css.gz +0 -0
  111. codex/static_root/js/choices.8c58714cf5b2.json +1 -0
  112. codex/static_root/js/choices.8c58714cf5b2.json.br +5 -0
  113. codex/static_root/js/choices.8c58714cf5b2.json.gz +0 -0
  114. codex/static_root/js/choices.json +1 -1
  115. codex/static_root/js/choices.json.br +0 -0
  116. codex/static_root/js/choices.json.gz +0 -0
  117. codex/static_root/{manifest.c0e270b2e6b6.json → manifest.d2f93a519ada.json} +32 -32
  118. codex/static_root/manifest.d2f93a519ada.json.br +0 -0
  119. codex/static_root/manifest.d2f93a519ada.json.gz +0 -0
  120. codex/static_root/manifest.json +32 -32
  121. codex/static_root/manifest.json.br +0 -0
  122. codex/static_root/manifest.json.gz +0 -0
  123. codex/static_root/staticfiles.json +1 -1
  124. codex/templates/headers-script-globals.html +1 -1
  125. codex/templates/{opds → opds_v1}/index.xml +3 -1
  126. codex/templates/{opds/opensearch.xml → opds_v1/opensearch_v1.xml} +1 -1
  127. codex/templates/search/indexes/codex/comic_text.txt +2 -2
  128. codex/urls/converters.py +1 -1
  129. codex/urls/opds/authentication.py +1 -1
  130. codex/urls/opds/root.py +8 -12
  131. codex/urls/opds/v1.py +12 -5
  132. codex/urls/opds/v2.py +2 -2
  133. codex/views/bookmark.py +2 -2
  134. codex/views/browser/base.py +23 -7
  135. codex/views/browser/browser.py +51 -41
  136. codex/views/browser/browser_annotations.py +159 -50
  137. codex/views/browser/browser_order_by.py +50 -106
  138. codex/views/browser/choices.py +75 -38
  139. codex/views/browser/filters/bookmark.py +6 -9
  140. codex/views/browser/filters/field.py +9 -6
  141. codex/views/browser/filters/group.py +12 -27
  142. codex/views/browser/filters/search.py +5 -10
  143. codex/views/browser/metadata.py +44 -19
  144. codex/views/download.py +1 -1
  145. codex/views/frontend.py +2 -3
  146. codex/views/mixins.py +15 -2
  147. codex/views/opds/const.py +8 -1
  148. codex/views/opds/util.py +37 -1
  149. codex/views/opds/v1/__init__.py +1 -1
  150. codex/views/opds/v1/data.py +21 -0
  151. codex/views/opds/v1/entry/__init__.py +1 -0
  152. codex/views/opds/v1/entry/data.py +23 -0
  153. codex/views/opds/v1/entry/entry.py +151 -0
  154. codex/views/opds/v1/entry/links.py +135 -0
  155. codex/views/opds/v1/facets.py +190 -0
  156. codex/views/opds/v1/feed.py +199 -0
  157. codex/views/opds/v1/links.py +198 -0
  158. codex/views/opds/{opensearch.py → v1/opensearch_v1.py} +3 -3
  159. codex/views/opds/v2/__init__.py +1 -1
  160. codex/views/opds/v2/const.py +10 -2
  161. codex/views/opds/v2/feed.py +82 -21
  162. codex/views/opds/v2/links.py +1 -1
  163. codex/views/opds/v2/publications.py +1 -1
  164. codex/views/opds/v2/top_links.py +1 -1
  165. codex/views/reader/page.py +6 -7
  166. codex/views/reader/reader.py +191 -61
  167. codex/views/session.py +2 -1
  168. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/METADATA +10 -41
  169. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/RECORD +172 -170
  170. codex/librarian/importer/db_ops.py +0 -251
  171. codex/pdf.py +0 -115
  172. codex/static_root/assets/admin-75c007ce.199fccf24c8d.js +0 -48
  173. codex/static_root/assets/admin-75c007ce.199fccf24c8d.js.br +0 -0
  174. codex/static_root/assets/admin-75c007ce.199fccf24c8d.js.gz +0 -0
  175. codex/static_root/assets/admin-75c007ce.js +0 -48
  176. codex/static_root/assets/admin-75c007ce.js.br +0 -0
  177. codex/static_root/assets/admin-75c007ce.js.gz +0 -0
  178. codex/static_root/assets/admin-848d48b1.5de8a0c45636.css +0 -1
  179. codex/static_root/assets/admin-848d48b1.5de8a0c45636.css.br +0 -0
  180. codex/static_root/assets/admin-848d48b1.5de8a0c45636.css.gz +0 -0
  181. codex/static_root/assets/admin-848d48b1.css +0 -1
  182. codex/static_root/assets/admin-848d48b1.css.br +0 -0
  183. codex/static_root/assets/admin-848d48b1.css.gz +0 -0
  184. codex/static_root/assets/admin-drawer-panel-a110c068.edf187333272.js +0 -1
  185. codex/static_root/assets/admin-drawer-panel-a110c068.edf187333272.js.br +0 -0
  186. codex/static_root/assets/admin-drawer-panel-a110c068.edf187333272.js.gz +0 -0
  187. codex/static_root/assets/admin-drawer-panel-a110c068.js +0 -1
  188. codex/static_root/assets/admin-drawer-panel-a110c068.js.br +0 -0
  189. codex/static_root/assets/admin-drawer-panel-a110c068.js.gz +0 -0
  190. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css +0 -1
  191. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css.br +0 -2
  192. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css.gz +0 -0
  193. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css +0 -1
  194. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css.br +0 -2
  195. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css.gz +0 -0
  196. codex/static_root/assets/browser-2c2380fd.8b515af7a743.js +0 -1
  197. codex/static_root/assets/browser-2c2380fd.8b515af7a743.js.br +0 -0
  198. codex/static_root/assets/browser-2c2380fd.8b515af7a743.js.gz +0 -0
  199. codex/static_root/assets/browser-2c2380fd.js +0 -1
  200. codex/static_root/assets/browser-2c2380fd.js.br +0 -0
  201. codex/static_root/assets/browser-2c2380fd.js.gz +0 -0
  202. codex/static_root/assets/browser-7325db61.css +0 -1
  203. codex/static_root/assets/browser-7325db61.css.br +0 -0
  204. codex/static_root/assets/browser-7325db61.css.gz +0 -0
  205. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css +0 -1
  206. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css.br +0 -0
  207. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css.gz +0 -0
  208. codex/static_root/assets/http-error-402decbe.9ea8de1df13f.js +0 -1
  209. codex/static_root/assets/http-error-402decbe.9ea8de1df13f.js.br +0 -0
  210. codex/static_root/assets/http-error-402decbe.9ea8de1df13f.js.gz +0 -0
  211. codex/static_root/assets/http-error-402decbe.js +0 -1
  212. codex/static_root/assets/http-error-402decbe.js.br +0 -0
  213. codex/static_root/assets/http-error-402decbe.js.gz +0 -0
  214. codex/static_root/assets/main-a7f327e9.6641fe833335.js +0 -1
  215. codex/static_root/assets/main-a7f327e9.6641fe833335.js.br +0 -0
  216. codex/static_root/assets/main-a7f327e9.6641fe833335.js.gz +0 -0
  217. codex/static_root/assets/main-a7f327e9.js +0 -1
  218. codex/static_root/assets/main-a7f327e9.js.br +0 -0
  219. codex/static_root/assets/main-a7f327e9.js.gz +0 -0
  220. codex/static_root/assets/metadata-dialog-785c4cfc.694a251cda37.css.br +0 -0
  221. codex/static_root/assets/metadata-dialog-785c4cfc.694a251cda37.css.gz +0 -0
  222. codex/static_root/assets/metadata-dialog-785c4cfc.css.br +0 -0
  223. codex/static_root/assets/metadata-dialog-785c4cfc.css.gz +0 -0
  224. codex/static_root/assets/metadata-dialog-8a0bd8e1.c213b08d582f.js +0 -1
  225. codex/static_root/assets/metadata-dialog-8a0bd8e1.c213b08d582f.js.br +0 -0
  226. codex/static_root/assets/metadata-dialog-8a0bd8e1.c213b08d582f.js.gz +0 -0
  227. codex/static_root/assets/metadata-dialog-8a0bd8e1.js +0 -1
  228. codex/static_root/assets/metadata-dialog-8a0bd8e1.js.br +0 -0
  229. codex/static_root/assets/metadata-dialog-8a0bd8e1.js.gz +0 -0
  230. codex/static_root/assets/page-pdf-c603e996.ab2d147c9ae1.js.br +0 -0
  231. codex/static_root/assets/page-pdf-c603e996.ab2d147c9ae1.js.gz +0 -0
  232. codex/static_root/assets/page-pdf-c603e996.js.br +0 -0
  233. codex/static_root/assets/page-pdf-c603e996.js.gz +0 -0
  234. codex/static_root/assets/reader-c2965a5f.b011260169f7.js +0 -1
  235. codex/static_root/assets/reader-c2965a5f.b011260169f7.js.br +0 -0
  236. codex/static_root/assets/reader-c2965a5f.b011260169f7.js.gz +0 -0
  237. codex/static_root/assets/reader-c2965a5f.js +0 -1
  238. codex/static_root/assets/reader-c2965a5f.js.br +0 -0
  239. codex/static_root/assets/reader-c2965a5f.js.gz +0 -0
  240. codex/static_root/assets/reader-d8534888.2821de925986.css +0 -1
  241. codex/static_root/assets/reader-d8534888.2821de925986.css.br +0 -0
  242. codex/static_root/assets/reader-d8534888.2821de925986.css.gz +0 -0
  243. codex/static_root/assets/reader-d8534888.css +0 -1
  244. codex/static_root/assets/reader-d8534888.css.br +0 -0
  245. codex/static_root/assets/reader-d8534888.css.gz +0 -0
  246. codex/static_root/js/choices.6bfc2a3d293f.json +0 -1
  247. codex/static_root/js/choices.6bfc2a3d293f.json.br +0 -0
  248. codex/static_root/js/choices.6bfc2a3d293f.json.gz +0 -0
  249. codex/static_root/manifest.c0e270b2e6b6.json.br +0 -0
  250. codex/static_root/manifest.c0e270b2e6b6.json.gz +0 -0
  251. codex/urls/opds/opensearch.py +0 -18
  252. codex/views/opds/v1/browser.py +0 -346
  253. codex/views/opds/v1/entry.py +0 -278
  254. codex/views/opds/v1/start.py +0 -28
  255. codex/views/opds/v1/util.py +0 -162
  256. codex/views/opds/v2/start.py +0 -28
  257. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/LICENSE +0 -0
  258. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/WHEEL +0 -0
  259. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,21 @@
1
1
  """View for marking comics read and unread."""
2
2
  import pycountry
3
3
  from caseconverter import snakecase
4
+ from django.db.models import QuerySet
4
5
  from drf_spectacular.utils import extend_schema
5
6
  from rest_framework.response import Response
6
7
 
7
8
  from codex.logger.logging import get_logger
8
- from codex.models import Comic, CreatorPerson
9
+ from codex.models import (
10
+ Comic,
11
+ CreatorPerson,
12
+ Folder,
13
+ Imprint,
14
+ Publisher,
15
+ Series,
16
+ StoryArc,
17
+ Volume,
18
+ )
9
19
  from codex.serializers.browser import (
10
20
  BrowserChoicesSerializer,
11
21
  BrowserFilterChoicesSerializer,
@@ -22,23 +32,37 @@ class BrowserChoicesViewBase(BrowserBaseView):
22
32
 
23
33
  permission_classes = [IsAuthenticatedOrEnabledNonUsers]
24
34
 
25
- CREATORS_PERSON_REL = "creators__person"
26
- NULL_NAMED_ROW = {"pk": -1, "name": "_none_"}
35
+ _CREATORS_PERSON_REL = "creators__person"
36
+ _STORY_ARC_REL = "story_arc_numbers__story_arc"
37
+ _NULL_NAMED_ROW = {"pk": -1, "name": "_none_"}
38
+ _BACK_REL_MAP = {CreatorPerson: "creator__", StoryArc: "storyarcnumber__"}
39
+ _REL_MAP = {
40
+ Publisher: "publisher",
41
+ Imprint: "imprint",
42
+ Series: "series",
43
+ Volume: "volume",
44
+ Comic: "pk",
45
+ Folder: "parent_folder",
46
+ StoryArc: "story_arc_numbers__story_arc",
47
+ }
27
48
 
28
49
  @staticmethod
29
- def get_field_choices_query(field_name, comic_qs):
50
+ def get_field_choices_query(comic_qs, field_name):
30
51
  """Get distinct values for the field."""
31
- return comic_qs.values_list(field_name, flat=True).distinct()
52
+ return (
53
+ comic_qs.exclude(**{field_name: None})
54
+ .values_list(field_name, flat=True)
55
+ .distinct()
56
+ )
32
57
 
33
- @classmethod
34
- def get_m2m_field_query(cls, rel, comic_qs, model):
58
+ def get_m2m_field_query(self, model, comic_qs: QuerySet):
35
59
  """Get distinct m2m value objects for the relation."""
36
- comic_rel = "creator__comic" if rel == cls.CREATORS_PERSON_REL else "comic"
60
+ back_rel = self._BACK_REL_MAP.get(model, "")
61
+ back_rel += "comic__"
62
+ back_rel += self._REL_MAP[self.model]
63
+ back_rel += "__in"
37
64
  return (
38
- model.objects.filter(**{f"{comic_rel}__in": comic_qs})
39
- .prefetch_related(comic_rel)
40
- .values("pk", "name")
41
- .distinct()
65
+ model.objects.filter(**{back_rel: comic_qs}).values("pk", "name").distinct()
42
66
  )
43
67
 
44
68
  @staticmethod
@@ -46,12 +70,14 @@ class BrowserChoicesViewBase(BrowserBaseView):
46
70
  """Get if null values exists for an m2m field."""
47
71
  return comic_qs.filter(**{f"{rel}__isnull": True}).exists()
48
72
 
49
- @classmethod
50
- def _get_rel_and_model(cls, field_name):
73
+ def _get_rel_and_model(self, field_name):
51
74
  """Return the relation and model for the field name."""
52
- if field_name == cls.CREATOR_PERSON_UI_FIELD:
53
- rel = cls.CREATORS_PERSON_REL
75
+ if field_name == self.CREATOR_PERSON_UI_FIELD:
76
+ rel = self._CREATORS_PERSON_REL
54
77
  model = CreatorPerson
78
+ elif field_name == self.STORY_ARC_UI_FIELD:
79
+ rel = self._STORY_ARC_REL
80
+ model = StoryArc
55
81
  else:
56
82
  remote_field = getattr(
57
83
  Comic._meta.get_field(field_name), "remote_field", None
@@ -59,12 +85,21 @@ class BrowserChoicesViewBase(BrowserBaseView):
59
85
  rel = field_name
60
86
  model = remote_field.model if remote_field else None
61
87
 
88
+ rel = self.rel_prefix + rel
89
+
62
90
  return rel, model
63
91
 
64
92
  def get_object(self):
65
93
  """Get the comic subquery use for the choices."""
66
- object_filter, _ = self.get_query_filters(True, True)
67
- return Comic.objects.filter(object_filter)
94
+ object_filter, _ = self.get_query_filters(self.model, True)
95
+ return self.model.objects.filter(object_filter)
96
+
97
+ def _set_model(self):
98
+ """Set the model to query."""
99
+ group = self.kwargs["group"]
100
+ if group == self.ROOT_GROUP:
101
+ group = self.params.get("top_group", "p")
102
+ self.model = self.GROUP_MODEL_MAP[group]
68
103
 
69
104
 
70
105
  class BrowserChoicesAvailableView(BrowserChoicesViewBase):
@@ -72,23 +107,20 @@ class BrowserChoicesAvailableView(BrowserChoicesViewBase):
72
107
 
73
108
  serializer_class = BrowserFilterChoicesSerializer
74
109
 
75
- CREATORS_PERSON_REL = "creators__person"
76
-
77
110
  @classmethod
78
- def _get_field_choices_count(cls, field_name, comic_qs):
111
+ def _get_field_choices_count(cls, comic_qs, field_name):
79
112
  """Create a pk:name object for fields without tables."""
80
- return cls.get_field_choices_query(field_name, comic_qs).count()
113
+ return cls.get_field_choices_query(comic_qs, field_name).count()
81
114
 
82
- @classmethod
83
- def _get_m2m_field_choices_count(cls, rel, comic_qs, model):
115
+ def _get_m2m_field_choices_count(self, model, comic_qs, rel):
84
116
  """Get choices with nulls where there are nulls."""
85
- count = cls.get_m2m_field_query(rel, comic_qs, model).count()
117
+ count = self.get_m2m_field_query(model, comic_qs).count()
86
118
 
87
119
  # Detect if there are null choices.
88
120
  # Regretabbly with another query, but doing a forward query
89
121
  # on the comic above restricts all results to only the filtered
90
122
  # rows. :(
91
- if cls.does_m2m_null_exist(comic_qs, rel):
123
+ if self.does_m2m_null_exist(comic_qs, rel):
92
124
  count += 1
93
125
 
94
126
  return count
@@ -97,16 +129,21 @@ class BrowserChoicesAvailableView(BrowserChoicesViewBase):
97
129
  def get(self, *args, **kwargs):
98
130
  """Return all choices with more than one choice."""
99
131
  self.parse_params()
132
+ self._set_model()
133
+ self.set_rel_prefix(self.model)
100
134
  comic_qs = self.get_object()
101
135
 
102
136
  data = {}
103
137
  for field_name in self.serializer_class().get_fields(): # type: ignore
138
+ if field_name == "story_arcs" and self.model == StoryArc:
139
+ # don't allow filtering on story arc in story arc view.
140
+ continue
104
141
  rel, m2m_model = self._get_rel_and_model(field_name)
105
142
 
106
143
  if m2m_model:
107
- count = self._get_m2m_field_choices_count(rel, comic_qs, m2m_model)
144
+ count = self._get_m2m_field_choices_count(m2m_model, comic_qs, rel)
108
145
  else:
109
- count = self._get_field_choices_count(rel, comic_qs)
146
+ count = self._get_field_choices_count(comic_qs, rel)
110
147
 
111
148
  filters = self.params.get("filters", {})
112
149
  data[field_name] = count > 1 or field_name in filters
@@ -120,10 +157,9 @@ class BrowserChoicesView(BrowserChoicesViewBase):
120
157
 
121
158
  serializer_class = BrowserChoicesSerializer
122
159
 
123
- @classmethod
124
- def _get_field_choices(cls, field_name, comic_qs):
160
+ def _get_field_choices(self, comic_qs, field_name):
125
161
  """Create a pk:name object for fields without tables."""
126
- qs = cls.get_field_choices_query(field_name, comic_qs)
162
+ qs = self.get_field_choices_query(comic_qs, field_name)
127
163
 
128
164
  if field_name == "country":
129
165
  lookup = pycountry.countries
@@ -139,18 +175,17 @@ class BrowserChoicesView(BrowserChoicesViewBase):
139
175
 
140
176
  return choices
141
177
 
142
- @classmethod
143
- def _get_m2m_field_choices(cls, rel, comic_qs, model):
178
+ def _get_m2m_field_choices(self, model, comic_qs, rel):
144
179
  """Get choices with nulls where there are nulls."""
145
- qs = cls.get_m2m_field_query(rel, comic_qs, model)
180
+ qs = self.get_m2m_field_query(model, comic_qs)
146
181
 
147
182
  # Detect if there are null choices.
148
183
  # Regretabbly with another query, but doing a forward query
149
184
  # on the comic above restrcts all results to only the filtered
150
185
  # rows. :(
151
- if cls.does_m2m_null_exist(comic_qs, rel):
186
+ if self.does_m2m_null_exist(comic_qs, rel):
152
187
  choices = list(qs)
153
- choices.append(cls.NULL_NAMED_ROW)
188
+ choices.append(self._NULL_NAMED_ROW)
154
189
  else:
155
190
  choices = qs
156
191
  return choices
@@ -159,6 +194,8 @@ class BrowserChoicesView(BrowserChoicesViewBase):
159
194
  def get(self, *args, **kwargs):
160
195
  """Return all choices with more than one choice."""
161
196
  self.parse_params()
197
+ self._set_model()
198
+ self.set_rel_prefix(self.model)
162
199
 
163
200
  field_name = snakecase(self.kwargs["field_name"])
164
201
 
@@ -166,9 +203,9 @@ class BrowserChoicesView(BrowserChoicesViewBase):
166
203
 
167
204
  comic_qs = self.get_object()
168
205
  if m2m_model:
169
- choices = self._get_m2m_field_choices(rel, comic_qs, m2m_model)
206
+ choices = self._get_m2m_field_choices(m2m_model, comic_qs, rel)
170
207
  else:
171
- choices = self._get_field_choices(rel, comic_qs)
208
+ choices = self._get_field_choices(comic_qs, rel)
172
209
 
173
210
  serializer = self.get_serializer(choices, many=True)
174
211
  return Response(serializer.data)
@@ -9,12 +9,10 @@ class BookmarkFilterMixin:
9
9
 
10
10
  _BOOKMARK_FILTERS = frozenset(set(CHOICES["bookmarkFilter"].keys()) - {"ALL"})
11
11
 
12
- def get_bm_rel(self, is_model_comic):
12
+ def get_bm_rel(self, model):
13
13
  """Create bookmark relation."""
14
- bm_rel = "bookmark"
15
- if not is_model_comic:
16
- bm_rel = "comic__" + bm_rel
17
- return bm_rel
14
+ rel_prefix = self.get_rel_prefix(model) # type: ignore
15
+ return rel_prefix + "bookmark"
18
16
 
19
17
  def _get_my_bookmark_filter(self, bm_rel):
20
18
  """Get a filter for my session or user defined bookmarks."""
@@ -27,12 +25,11 @@ class BookmarkFilterMixin:
27
25
  }
28
26
  return Q(**my_bookmarks_kwargs)
29
27
 
30
- def get_bookmark_filter(self, is_model_comic, choice):
28
+ def get_bookmark_filter(self, model):
31
29
  """Build bookmark query."""
32
- if choice is None:
33
- choice = self.params["filters"].get("bookmark", "ALL") # type: ignore
30
+ choice = self.params["filters"].get("bookmark", "ALL") # type: ignore
34
31
  if choice in self._BOOKMARK_FILTERS:
35
- bm_rel = self.get_bm_rel(is_model_comic)
32
+ bm_rel = self.get_bm_rel(model)
36
33
  my_bookmark_filter = self._get_my_bookmark_filter(bm_rel)
37
34
  if choice in ("UNREAD", "IN_PROGRESS"):
38
35
  my_not_finished_filter = my_bookmark_filter & Q(
@@ -7,18 +7,21 @@ from codex.views.session import BrowserSessionViewBase
7
7
  class ComicFieldFilter(BrowserSessionViewBase):
8
8
  """Comic field filters."""
9
9
 
10
- def _filter_by_comic_field(self, field, is_model_comic):
10
+ def _filter_by_comic_field(self, field):
11
11
  """Filter by a comic any2many attribute."""
12
12
  filter_list = self.params["filters"].get(field) # type: ignore
13
13
  filter_query = Q()
14
14
  if not filter_list:
15
15
  return filter_query
16
- query_prefix = "" if is_model_comic else "comic__"
17
16
 
18
17
  if field == self.CREATOR_PERSON_UI_FIELD:
19
- rel = f"{query_prefix}creators__person"
18
+ rel = "creators__person"
19
+ elif field == self.STORY_ARC_UI_FIELD:
20
+ rel = "storyarcnumber__story_arc"
20
21
  else:
21
- rel = f"{query_prefix}{field}"
22
+ rel = field
23
+
24
+ rel = self.rel_prefix + rel # type: ignore
22
25
 
23
26
  for index, val in enumerate(filter_list):
24
27
  # None values in a list don't work right so test for them separately
@@ -29,9 +32,9 @@ class ComicFieldFilter(BrowserSessionViewBase):
29
32
  filter_query |= Q(**{f"{rel}__in": filter_list})
30
33
  return filter_query
31
34
 
32
- def get_comic_field_filter(self, is_model_comic):
35
+ def get_comic_field_filter(self):
33
36
  """Filter the comics based on the form filters."""
34
37
  comic_field_filter = Q()
35
38
  for attribute in self.FILTER_ATTRIBUTES:
36
- comic_field_filter &= self._filter_by_comic_field(attribute, is_model_comic)
39
+ comic_field_filter &= self._filter_by_comic_field(attribute)
37
40
  return comic_field_filter
@@ -7,35 +7,20 @@ from codex.views.mixins import GroupACLMixin
7
7
  class GroupFilterMixin(GroupACLMixin):
8
8
  """Group Filters."""
9
9
 
10
- def _get_folders_filter(self):
11
- """Get a filter for ALL parent folders not just immediate one."""
12
- pk = self.kwargs.get("pk") # type: ignore
13
- return Q(folders__in=[pk]) if pk else Q()
14
-
15
- def _get_browser_group_filter(self):
16
- """Get the objects we'll be displaying."""
17
- # Get the instances that are children of the group_instance
18
- # And the filtered comics that are children of the group_instance
19
- group_filter = Q()
20
- pk = self.kwargs.get("pk") # type: ignore
21
- group = self.kwargs.get("group") # type: ignore
22
- if pk or group == self.FOLDER_GROUP:
23
- if not pk:
24
- pk = None
25
- group_relation = self.GROUP_RELATION[group]
26
- group_filter |= Q(**{group_relation: pk})
27
-
28
- return group_filter
29
-
30
10
  def get_group_filter(self, choices):
31
11
  """Get filter for the displayed group."""
32
- is_folder_view = self.kwargs.get("group") == self.FOLDER_GROUP # type: ignore
33
- if is_folder_view and choices:
34
- # Choice view needs to get all descendant comic attributes
35
- # So filter by all the folders
36
- group_filter = self._get_folders_filter()
12
+ pk = self.kwargs.get("pk") # type: ignore
13
+ group = self.kwargs.get("group") # type: ignore
14
+ if pk:
15
+ group_relation = "comic__" if choices else ""
16
+ if choices and group == self.FOLDER_GROUP:
17
+ group_relation += "folders"
18
+ else:
19
+ group_relation += self.GROUP_RELATION[group]
20
+ group_filter = Q(**{group_relation: pk})
21
+ elif group == self.FOLDER_GROUP:
22
+ group_filter = Q(parent_folder=None)
37
23
  else:
38
- # The browser filter is the same for all views
39
- group_filter = self._get_browser_group_filter()
24
+ group_filter = Q()
40
25
 
41
26
  return group_filter
@@ -23,19 +23,14 @@ class SearchFilterMixin:
23
23
  LOG.warning("While searching:")
24
24
  LOG.exception(exc)
25
25
 
26
- def _get_search_query_filter(self, text, is_model_comic, search_scores):
26
+ def _get_search_query_filter(self, text, search_scores):
27
27
  """Get the search filter and scores."""
28
- # Get search scores
28
+ rel = self.rel_prefix + "pk__in" # type: ignore
29
29
  self._get_search_scores(text, search_scores)
30
-
31
- # Create query
32
- prefix = ""
33
- if not is_model_comic:
34
- prefix = "comic__"
35
- query_dict = {f"{prefix}pk__in": search_scores.keys()}
30
+ query_dict = {rel: search_scores.keys()}
36
31
  return Q(**query_dict)
37
32
 
38
- def get_search_filter(self, is_model_comic):
33
+ def get_search_filter(self):
39
34
  """Preparse search, search and return the filter and scores."""
40
35
  search_filter = Q()
41
36
  search_scores = {}
@@ -48,7 +43,7 @@ class SearchFilterMixin:
48
43
  if query_string:
49
44
  # Query haystack
50
45
  search_filter = self._get_search_query_filter(
51
- query_string, is_model_comic, search_scores
46
+ query_string, search_scores
52
47
  )
53
48
  except Exception as exc:
54
49
  LOG.warning(exc)
@@ -7,7 +7,7 @@ from rest_framework.exceptions import NotFound
7
7
  from rest_framework.response import Response
8
8
 
9
9
  from codex.comic_field_names import COMIC_M2M_FIELD_NAMES
10
- from codex.models import AdminFlag, Comic
10
+ from codex.models import AdminFlag, Comic, StoryArc
11
11
  from codex.serializers.metadata import MetadataSerializer
12
12
  from codex.views.auth import IsAuthenticatedOrEnabledNonUsers
13
13
  from codex.views.browser.browser_annotations import BrowserAnnotationsView
@@ -28,6 +28,7 @@ class MetadataView(BrowserAnnotationsView):
28
28
  "critical_rating",
29
29
  "day",
30
30
  "file_type",
31
+ "gtin",
31
32
  "issue",
32
33
  "issue_suffix",
33
34
  "language",
@@ -56,6 +57,7 @@ class MetadataView(BrowserAnnotationsView):
56
57
  _COMIC_RELATED_VALUE_FIELDS = {"series__volume_count", "volume__issue_count"}
57
58
  _PATH_GROUPS = ("c", "f")
58
59
  _CREATOR_RELATIONS = ("role", "person")
60
+ _STORY_ARC_NUMBER_RELATIONS = ("story_arc",)
59
61
 
60
62
  def _get_comic_value_fields(self):
61
63
  """Include the path field for staff."""
@@ -79,7 +81,7 @@ class MetadataView(BrowserAnnotationsView):
79
81
  # Have to use simple_qs because every annotation in the loop
80
82
  # corrupts the the main qs
81
83
  # If 1 variant, annotate value, otherwise None
82
- full_field = field if self.is_model_comic else "comic__" + field
84
+ full_field = self.rel_prefix + field
83
85
 
84
86
  sq = (
85
87
  simple_qs.values("id")
@@ -105,7 +107,7 @@ class MetadataView(BrowserAnnotationsView):
105
107
  def _annotate_aggregates(self, qs):
106
108
  """Annotate aggregate values."""
107
109
  if not self.is_model_comic:
108
- size_func = self.get_aggregate_func("size", self.is_model_comic)
110
+ size_func = self.get_aggregate_func(self.model, "size")
109
111
  qs = qs.annotate(size=size_func)
110
112
  qs = self.annotate_common_aggregates(qs, self.model, {})
111
113
  return qs
@@ -135,18 +137,32 @@ class MetadataView(BrowserAnnotationsView):
135
137
  )
136
138
  return qs
137
139
 
138
- def _annotate_for_filename(self, qs):
139
- """Annotate for the filename function."""
140
- if not self.is_model_comic:
141
- return qs
142
- qs = qs.annotate(parent_folder_pk=F("parent_folder_id"))
143
- return qs.annotate(series_name=F("series__name"), volume_name=F("volume__name"))
140
+ @staticmethod
141
+ def _get_intersection_queryset(qs, values, count_rel, comic_pks):
142
+ """Create an intersection queryset."""
143
+ return (
144
+ qs.only(*values)
145
+ .annotate(count=Count(count_rel))
146
+ .order_by()
147
+ .filter(count=comic_pks.count())
148
+ )
149
+
150
+ @classmethod
151
+ def _get_story_arc_intersection_queryset(cls, comic_pks):
152
+ """Hoist Story Arc intersections up from StoryArcNumber."""
153
+ qs = StoryArc.objects.filter(storyarcnumber__comic__pk__in=comic_pks)
154
+ return cls._get_intersection_queryset(
155
+ qs,
156
+ ("name",),
157
+ "storyarcnumber__comic",
158
+ comic_pks,
159
+ )
144
160
 
145
161
  def _query_m2m_intersections(self, simple_qs):
146
162
  """Query the through models to figure out m2m intersections."""
147
163
  # Speed ok, but still does a query per m2m model
148
164
  m2m_intersections = {}
149
- pk_field = "pk" if self.is_model_comic else "comic__pk"
165
+ pk_field = self.rel_prefix + "pk"
150
166
  comic_pks = simple_qs.values_list(pk_field, flat=True)
151
167
  for field_name in COMIC_M2M_FIELD_NAMES:
152
168
  model = Comic._meta.get_field(field_name).related_model
@@ -156,19 +172,28 @@ class MetadataView(BrowserAnnotationsView):
156
172
 
157
173
  intersection_qs = model.objects.filter(comic__pk__in=comic_pks)
158
174
  if field_name == "creators":
175
+ # XXX This doesn't prevent an n+1 warning
159
176
  intersection_qs = intersection_qs.select_related(
160
177
  *self._CREATOR_RELATIONS
161
178
  )
162
179
  values = self._CREATOR_RELATIONS
180
+ elif field_name == "story_arc_numbers":
181
+ # XXX This doesn't prevent an n+1 warning
182
+ intersection_qs = intersection_qs.select_related(
183
+ *self._STORY_ARC_NUMBER_RELATIONS
184
+ )
185
+ values = self._STORY_ARC_NUMBER_RELATIONS
186
+
187
+ # Extra add on m2m
188
+ m2m_intersections[
189
+ "story_arcs"
190
+ ] = self._get_story_arc_intersection_queryset(comic_pks)
163
191
  else:
164
192
  values = ("name",)
165
193
 
166
194
  # order_by() is very important for grouping
167
- intersection_qs = (
168
- intersection_qs.only(*values)
169
- .annotate(count=Count("comic"))
170
- .order_by()
171
- .filter(count=comic_pks.count())
195
+ intersection_qs = self._get_intersection_queryset(
196
+ intersection_qs, values, "comic", comic_pks
172
197
  )
173
198
  m2m_intersections[field_name] = intersection_qs
174
199
  return m2m_intersections
@@ -221,7 +246,7 @@ class MetadataView(BrowserAnnotationsView):
221
246
 
222
247
  # filename
223
248
  if self.model == Comic:
224
- obj.filename = Comic.get_filename(obj)
249
+ obj.filename = obj.filename()
225
250
 
226
251
  return obj
227
252
 
@@ -234,7 +259,7 @@ class MetadataView(BrowserAnnotationsView):
234
259
  if self.model is None:
235
260
  raise NotFound(detail=f"Cannot get metadata for {self.group=}")
236
261
 
237
- object_filter, _ = self.get_query_filters_without_group(self.is_model_comic)
262
+ object_filter, _ = self.get_query_filters_without_group(self.model)
238
263
  pk = self.kwargs["pk"]
239
264
  qs = self.model.objects.filter(object_filter, pk=pk)
240
265
 
@@ -242,10 +267,8 @@ class MetadataView(BrowserAnnotationsView):
242
267
  simple_qs = qs
243
268
 
244
269
  qs = self._annotate_values_and_fks(qs, simple_qs)
245
- qs = self._annotate_for_filename(qs)
246
270
 
247
271
  try:
248
- # obj = qs.values()[0]
249
272
  obj = qs.first()
250
273
  if not obj:
251
274
  reason = "Empty obj"
@@ -282,6 +305,8 @@ class MetadataView(BrowserAnnotationsView):
282
305
  self.parse_params()
283
306
  self.group = self.kwargs["group"]
284
307
  self._validate()
308
+ self.rel_prefix = self.get_rel_prefix(self.model)
309
+ self.set_order_key()
285
310
 
286
311
  obj = self.get_object()
287
312
 
codex/views/download.py CHANGED
@@ -25,7 +25,7 @@ class DownloadView(APIView, GroupACLMixin):
25
25
  """Download a comic archive."""
26
26
  pk = kwargs.get("pk")
27
27
  try:
28
- group_acl_filter = self.get_group_acl_filter(True)
28
+ group_acl_filter = self.get_group_acl_filter(Comic)
29
29
  comic = (
30
30
  Comic.objects.filter(group_acl_filter)
31
31
  .select_related(*self._DOWNLOAD_SELECT_RELATED)
codex/views/frontend.py CHANGED
@@ -14,7 +14,6 @@ class IndexView(BrowserSessionViewBase):
14
14
 
15
15
  def get(self, *args, **kwargs):
16
16
  """Get the app index page."""
17
- extra_context = {
18
- "last_route": self.get_from_session("route"),
19
- }
17
+ last_route = self.get_from_session("route")
18
+ extra_context = {"last_route": last_route}
20
19
  return Response(extra_context)
codex/views/mixins.py CHANGED
@@ -1,12 +1,15 @@
1
1
  """A filter for group ACLS."""
2
2
  from django.db.models import Q
3
3
 
4
+ from codex.models import Comic, Folder, StoryArc
5
+
4
6
 
5
7
  class GroupACLMixin:
6
8
  """Filter group ACLS for views."""
7
9
 
8
10
  ROOT_GROUP = "r"
9
11
  FOLDER_GROUP = "f"
12
+ STORY_ARC_GROUP = "a"
10
13
  COMIC_GROUP = "c"
11
14
  GROUP_RELATION = {
12
15
  "p": "publisher",
@@ -15,12 +18,22 @@ class GroupACLMixin:
15
18
  "v": "volume",
16
19
  COMIC_GROUP: "pk",
17
20
  FOLDER_GROUP: "parent_folder",
21
+ STORY_ARC_GROUP: "story_arc_numbers__story_arc",
18
22
  }
19
23
 
20
- def get_group_acl_filter(self, is_comic_model):
24
+ def get_rel_prefix(self, model):
25
+ """Return the relation prfiex for most fields."""
26
+ prefix = ""
27
+ if model != Comic:
28
+ if model == StoryArc:
29
+ prefix += "storyarcnumber__"
30
+ prefix += "comic__"
31
+ return prefix
32
+
33
+ def get_group_acl_filter(self, model):
21
34
  """Generate the group acl filter for comics."""
22
35
  # The rel prefix
23
- prefix = "" if is_comic_model else "comic__"
36
+ prefix = self.get_rel_prefix(model) if model != Folder else ""
24
37
  groups_rel = f"{prefix}library__groups"
25
38
 
26
39
  # Libraries with no groups are always visible
codex/views/opds/const.py CHANGED
@@ -15,6 +15,7 @@ class Rel:
15
15
  IMAGE = "http://opds-spec.org/image"
16
16
  STREAM = "http://vaemendis.net/opds-pse/stream"
17
17
  SORT_NEW = "http://opds-spec.org/sort/new"
18
+ POPULAR = "http://opds-spec.org/sort/popular"
18
19
  FEATURED = "http://opds-spec.org/featured"
19
20
  SELF = "self"
20
21
  START = "start"
@@ -40,7 +41,6 @@ class MimeType:
40
41
  ENTRY_CATALOG = ";".join((ATOM, "type=entry", _PROFILE_CATALOG))
41
42
  AUTHENTICATION = "application/opds-authentication+json"
42
43
  OPENSEARCH = "application/opensearchdescription+xml"
43
- DOWNLOAD = "application/zip" # PocketBooks needs app/zip
44
44
  STREAM = "image/jpeg"
45
45
  OPDS_JSON = "application/opds+json"
46
46
  OPDS_PUB = "application/opds-publication+json"
@@ -57,4 +57,11 @@ class MimeType:
57
57
  "CBT": "application/vnd.comicbook+tar",
58
58
  "PDF": "application/pdf",
59
59
  }
60
+ SIMPLE_FILE_TYPE_MAP = {
61
+ # PocketBooks needs app/zip
62
+ "CBZ": "application/zip",
63
+ "CBR": "application/x-rar-compressed",
64
+ "CBT": "application/x-tar",
65
+ "PDF": "application/pdf",
66
+ }
60
67
  OCTET = "application/octet-stream"