codex 1.4.0a0__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 (279) 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/coverd.py +3 -11
  5. codex/librarian/covers/create.py +15 -21
  6. codex/librarian/covers/tasks.py +2 -16
  7. codex/librarian/importer/aggregate_metadata.py +75 -41
  8. codex/librarian/importer/clean_metadata.py +30 -7
  9. codex/librarian/importer/create_fks.py +154 -55
  10. codex/librarian/importer/deleted.py +11 -2
  11. codex/librarian/importer/failed_imports.py +44 -5
  12. codex/librarian/importer/importerd.py +37 -12
  13. codex/librarian/importer/link_comics.py +54 -31
  14. codex/librarian/importer/moved.py +55 -11
  15. codex/librarian/importer/query_fks.py +210 -48
  16. codex/librarian/importer/tasks.py +7 -7
  17. codex/librarian/janitor/cleanup.py +17 -5
  18. codex/librarian/librariand.py +10 -0
  19. codex/librarian/watchdog/events.py +11 -14
  20. codex/librarian/watchdog/observers.py +5 -1
  21. codex/logger/loggerd.py +7 -3
  22. codex/logger/logging.py +1 -1
  23. codex/migrations/0024_comic_gtin_comic_story_arc_number.py +24 -0
  24. codex/migrations/0025_add_story_arc_number.py +83 -0
  25. codex/models.py +21 -11
  26. codex/search/backend.py +1 -1
  27. codex/search/indexes.py +1 -1
  28. codex/serializers/browser.py +1 -0
  29. codex/serializers/metadata.py +5 -1
  30. codex/serializers/models.py +16 -1
  31. codex/serializers/opds/v1.py +1 -0
  32. codex/serializers/opds/v2.py +5 -2
  33. codex/serializers/reader.py +55 -16
  34. codex/settings/settings.py +1 -1
  35. codex/static_root/assets/admin-12749881.ef0f50bac290.js +41 -0
  36. codex/static_root/assets/admin-12749881.ef0f50bac290.js.br +0 -0
  37. codex/static_root/assets/admin-12749881.ef0f50bac290.js.gz +0 -0
  38. codex/static_root/assets/admin-12749881.js +41 -0
  39. codex/static_root/assets/admin-12749881.js.br +0 -0
  40. codex/static_root/assets/admin-12749881.js.gz +0 -0
  41. codex/static_root/assets/admin-beda768d.a614eee46307.css +1 -0
  42. codex/static_root/assets/admin-beda768d.a614eee46307.css.br +0 -0
  43. codex/static_root/assets/admin-beda768d.a614eee46307.css.gz +0 -0
  44. codex/static_root/assets/admin-beda768d.css +1 -0
  45. codex/static_root/assets/admin-beda768d.css.br +0 -0
  46. codex/static_root/assets/admin-beda768d.css.gz +0 -0
  47. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css +1 -0
  48. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css.br +0 -0
  49. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css.gz +0 -0
  50. codex/static_root/assets/admin-drawer-panel-41c225cc.css +1 -0
  51. codex/static_root/assets/admin-drawer-panel-41c225cc.css.br +0 -0
  52. codex/static_root/assets/admin-drawer-panel-41c225cc.css.gz +0 -0
  53. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js +1 -0
  54. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js.br +0 -0
  55. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js.gz +0 -0
  56. codex/static_root/assets/admin-drawer-panel-522f1e6c.js +1 -0
  57. codex/static_root/assets/admin-drawer-panel-522f1e6c.js.br +0 -0
  58. codex/static_root/assets/admin-drawer-panel-522f1e6c.js.gz +0 -0
  59. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css +1 -0
  60. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css.br +0 -0
  61. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css.gz +0 -0
  62. codex/static_root/assets/browser-7f7d7134.css +1 -0
  63. codex/static_root/assets/browser-7f7d7134.css.br +0 -0
  64. codex/static_root/assets/browser-7f7d7134.css.gz +0 -0
  65. codex/static_root/assets/browser-af622672.d51aca96d64d.js +1 -0
  66. codex/static_root/assets/browser-af622672.d51aca96d64d.js.br +0 -0
  67. codex/static_root/assets/browser-af622672.d51aca96d64d.js.gz +0 -0
  68. codex/static_root/assets/browser-af622672.js +1 -0
  69. codex/static_root/assets/browser-af622672.js.br +0 -0
  70. codex/static_root/assets/browser-af622672.js.gz +0 -0
  71. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js +1 -0
  72. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js.br +0 -0
  73. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js.gz +0 -0
  74. codex/static_root/assets/http-error-5e17b794.js +1 -0
  75. codex/static_root/assets/http-error-5e17b794.js.br +0 -0
  76. codex/static_root/assets/http-error-5e17b794.js.gz +0 -0
  77. codex/static_root/assets/{main-a6ac9581.2fd9e52cbcc3.css → main-0898f4bb.181e0145c642.css} +1 -1
  78. codex/static_root/assets/main-0898f4bb.181e0145c642.css.br +0 -0
  79. codex/static_root/assets/{main-a6ac9581.2fd9e52cbcc3.css.gz → main-0898f4bb.181e0145c642.css.gz} +0 -0
  80. codex/static_root/assets/{main-a6ac9581.css → main-0898f4bb.css} +1 -1
  81. codex/static_root/assets/main-0898f4bb.css.br +0 -0
  82. codex/static_root/assets/{main-a6ac9581.css.gz → main-0898f4bb.css.gz} +0 -0
  83. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js +1 -0
  84. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js.br +0 -0
  85. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js.gz +0 -0
  86. codex/static_root/assets/main-9e76a4c3.js +1 -0
  87. codex/static_root/assets/main-9e76a4c3.js.br +0 -0
  88. codex/static_root/assets/main-9e76a4c3.js.gz +0 -0
  89. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js +1 -0
  90. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js.br +0 -0
  91. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js.gz +0 -0
  92. codex/static_root/assets/metadata-dialog-62c29ce0.js +1 -0
  93. codex/static_root/assets/metadata-dialog-62c29ce0.js.br +0 -0
  94. codex/static_root/assets/metadata-dialog-62c29ce0.js.gz +0 -0
  95. codex/static_root/assets/{metadata-dialog-785c4cfc.694a251cda37.css → metadata-dialog-cb306ffd.cc304996d7bb.css} +1 -1
  96. codex/static_root/assets/metadata-dialog-cb306ffd.cc304996d7bb.css.br +0 -0
  97. codex/static_root/assets/metadata-dialog-cb306ffd.cc304996d7bb.css.gz +0 -0
  98. codex/static_root/assets/{metadata-dialog-785c4cfc.css → metadata-dialog-cb306ffd.css} +1 -1
  99. codex/static_root/assets/metadata-dialog-cb306ffd.css.br +0 -0
  100. codex/static_root/assets/metadata-dialog-cb306ffd.css.gz +0 -0
  101. codex/static_root/assets/{page-pdf-abfd509d.3870dab8eaf4.js → page-pdf-157ba97e.613d7c2beb77.js} +61 -51
  102. codex/static_root/assets/page-pdf-157ba97e.613d7c2beb77.js.br +0 -0
  103. codex/static_root/assets/page-pdf-157ba97e.613d7c2beb77.js.gz +0 -0
  104. codex/static_root/assets/{page-pdf-abfd509d.js → page-pdf-157ba97e.js} +61 -51
  105. codex/static_root/assets/page-pdf-157ba97e.js.br +0 -0
  106. codex/static_root/assets/page-pdf-157ba97e.js.gz +0 -0
  107. codex/static_root/assets/reader-36266549.0b2cf1291f27.js +1 -0
  108. codex/static_root/assets/reader-36266549.0b2cf1291f27.js.br +0 -0
  109. codex/static_root/assets/reader-36266549.0b2cf1291f27.js.gz +0 -0
  110. codex/static_root/assets/reader-36266549.js +1 -0
  111. codex/static_root/assets/reader-36266549.js.br +0 -0
  112. codex/static_root/assets/reader-36266549.js.gz +0 -0
  113. codex/static_root/assets/reader-7f004141.506eecc6954b.css +1 -0
  114. codex/static_root/assets/reader-7f004141.506eecc6954b.css.br +0 -0
  115. codex/static_root/assets/reader-7f004141.506eecc6954b.css.gz +0 -0
  116. codex/static_root/assets/reader-7f004141.css +1 -0
  117. codex/static_root/assets/reader-7f004141.css.br +0 -0
  118. codex/static_root/assets/reader-7f004141.css.gz +0 -0
  119. codex/static_root/js/choices-admin.24cecf0a0568.json +1 -0
  120. codex/static_root/js/choices-admin.24cecf0a0568.json.br +0 -0
  121. codex/static_root/js/choices-admin.24cecf0a0568.json.gz +0 -0
  122. codex/static_root/js/choices-admin.json +1 -1
  123. codex/static_root/js/choices-admin.json.br +0 -0
  124. codex/static_root/js/choices-admin.json.gz +0 -0
  125. codex/static_root/js/choices.8c58714cf5b2.json +1 -0
  126. codex/static_root/js/choices.8c58714cf5b2.json.br +5 -0
  127. codex/static_root/js/choices.8c58714cf5b2.json.gz +0 -0
  128. codex/static_root/js/choices.json +1 -1
  129. codex/static_root/js/choices.json.br +0 -0
  130. codex/static_root/js/choices.json.gz +0 -0
  131. codex/static_root/{manifest.64a989215af8.json → manifest.d2f93a519ada.json} +34 -34
  132. codex/static_root/manifest.d2f93a519ada.json.br +0 -0
  133. codex/static_root/manifest.d2f93a519ada.json.gz +0 -0
  134. codex/static_root/manifest.json +34 -34
  135. codex/static_root/manifest.json.br +0 -0
  136. codex/static_root/manifest.json.gz +0 -0
  137. codex/static_root/staticfiles.json +1 -1
  138. codex/templates/headers-script-globals.html +1 -1
  139. codex/templates/{opds → opds_v1}/index.xml +3 -1
  140. codex/templates/{opds/opensearch.xml → opds_v1/opensearch_v1.xml} +1 -1
  141. codex/templates/search/indexes/codex/comic_text.txt +2 -2
  142. codex/urls/converters.py +1 -1
  143. codex/urls/opds/authentication.py +1 -1
  144. codex/urls/opds/root.py +8 -12
  145. codex/urls/opds/v1.py +12 -5
  146. codex/urls/opds/v2.py +2 -2
  147. codex/views/admin/tasks.py +6 -1
  148. codex/views/bookmark.py +2 -2
  149. codex/views/browser/base.py +23 -7
  150. codex/views/browser/browser.py +66 -56
  151. codex/views/browser/browser_annotations.py +159 -50
  152. codex/views/browser/browser_order_by.py +51 -105
  153. codex/views/browser/choices.py +75 -38
  154. codex/views/browser/filters/bookmark.py +6 -9
  155. codex/views/browser/filters/field.py +9 -6
  156. codex/views/browser/filters/group.py +12 -27
  157. codex/views/browser/filters/search.py +5 -10
  158. codex/views/browser/metadata.py +44 -19
  159. codex/views/download.py +1 -1
  160. codex/views/frontend.py +2 -3
  161. codex/views/mixins.py +15 -2
  162. codex/views/opds/const.py +8 -1
  163. codex/views/opds/util.py +37 -1
  164. codex/views/opds/v1/__init__.py +1 -1
  165. codex/views/opds/v1/data.py +21 -0
  166. codex/views/opds/v1/entry/__init__.py +1 -0
  167. codex/views/opds/v1/entry/data.py +23 -0
  168. codex/views/opds/v1/entry/entry.py +151 -0
  169. codex/views/opds/v1/entry/links.py +135 -0
  170. codex/views/opds/v1/facets.py +190 -0
  171. codex/views/opds/v1/feed.py +199 -0
  172. codex/views/opds/v1/links.py +198 -0
  173. codex/views/opds/{opensearch.py → v1/opensearch_v1.py} +3 -3
  174. codex/views/opds/v2/__init__.py +1 -1
  175. codex/views/opds/v2/const.py +10 -2
  176. codex/views/opds/v2/feed.py +82 -21
  177. codex/views/opds/v2/links.py +1 -1
  178. codex/views/opds/v2/publications.py +1 -1
  179. codex/views/opds/v2/top_links.py +1 -1
  180. codex/views/reader/page.py +6 -7
  181. codex/views/reader/reader.py +191 -61
  182. codex/views/session.py +2 -1
  183. {codex-1.4.0a0.dist-info → codex-1.4.1.dist-info}/METADATA +10 -41
  184. {codex-1.4.0a0.dist-info → codex-1.4.1.dist-info}/RECORD +187 -185
  185. codex/librarian/importer/db_ops.py +0 -248
  186. codex/pdf.py +0 -115
  187. codex/static_root/assets/admin-73d93dc7.2c3eb62e50a0.js +0 -48
  188. codex/static_root/assets/admin-73d93dc7.2c3eb62e50a0.js.br +0 -0
  189. codex/static_root/assets/admin-73d93dc7.2c3eb62e50a0.js.gz +0 -0
  190. codex/static_root/assets/admin-73d93dc7.js +0 -48
  191. codex/static_root/assets/admin-73d93dc7.js.br +0 -0
  192. codex/static_root/assets/admin-73d93dc7.js.gz +0 -0
  193. codex/static_root/assets/admin-79555229.5f2c4cb3a73c.css +0 -1
  194. codex/static_root/assets/admin-79555229.5f2c4cb3a73c.css.br +0 -0
  195. codex/static_root/assets/admin-79555229.5f2c4cb3a73c.css.gz +0 -0
  196. codex/static_root/assets/admin-79555229.css +0 -1
  197. codex/static_root/assets/admin-79555229.css.br +0 -0
  198. codex/static_root/assets/admin-79555229.css.gz +0 -0
  199. codex/static_root/assets/admin-drawer-panel-64bcc083.a85324c9ccd8.js +0 -1
  200. codex/static_root/assets/admin-drawer-panel-64bcc083.a85324c9ccd8.js.br +0 -0
  201. codex/static_root/assets/admin-drawer-panel-64bcc083.a85324c9ccd8.js.gz +0 -0
  202. codex/static_root/assets/admin-drawer-panel-64bcc083.js +0 -1
  203. codex/static_root/assets/admin-drawer-panel-64bcc083.js.br +0 -0
  204. codex/static_root/assets/admin-drawer-panel-64bcc083.js.gz +0 -0
  205. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css +0 -1
  206. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css.br +0 -2
  207. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css.gz +0 -0
  208. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css +0 -1
  209. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css.br +0 -2
  210. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css.gz +0 -0
  211. codex/static_root/assets/browser-7325db61.css +0 -1
  212. codex/static_root/assets/browser-7325db61.css.br +0 -0
  213. codex/static_root/assets/browser-7325db61.css.gz +0 -0
  214. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css +0 -1
  215. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css.br +0 -0
  216. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css.gz +0 -0
  217. codex/static_root/assets/browser-d2caeed7.2262000a6d55.js +0 -1
  218. codex/static_root/assets/browser-d2caeed7.2262000a6d55.js.br +0 -0
  219. codex/static_root/assets/browser-d2caeed7.2262000a6d55.js.gz +0 -0
  220. codex/static_root/assets/browser-d2caeed7.js +0 -1
  221. codex/static_root/assets/browser-d2caeed7.js.br +0 -0
  222. codex/static_root/assets/browser-d2caeed7.js.gz +0 -0
  223. codex/static_root/assets/http-error-0221c37d.480d5066da92.js +0 -1
  224. codex/static_root/assets/http-error-0221c37d.480d5066da92.js.br +0 -0
  225. codex/static_root/assets/http-error-0221c37d.480d5066da92.js.gz +0 -0
  226. codex/static_root/assets/http-error-0221c37d.js +0 -1
  227. codex/static_root/assets/http-error-0221c37d.js.br +0 -0
  228. codex/static_root/assets/http-error-0221c37d.js.gz +0 -0
  229. codex/static_root/assets/main-a6ac9581.2fd9e52cbcc3.css.br +0 -0
  230. codex/static_root/assets/main-a6ac9581.css.br +0 -0
  231. codex/static_root/assets/main-e33dcfb0.a65044fc1a08.js +0 -1
  232. codex/static_root/assets/main-e33dcfb0.a65044fc1a08.js.br +0 -0
  233. codex/static_root/assets/main-e33dcfb0.a65044fc1a08.js.gz +0 -0
  234. codex/static_root/assets/main-e33dcfb0.js +0 -1
  235. codex/static_root/assets/main-e33dcfb0.js.br +0 -0
  236. codex/static_root/assets/main-e33dcfb0.js.gz +0 -0
  237. codex/static_root/assets/metadata-dialog-785c4cfc.694a251cda37.css.br +0 -0
  238. codex/static_root/assets/metadata-dialog-785c4cfc.694a251cda37.css.gz +0 -0
  239. codex/static_root/assets/metadata-dialog-785c4cfc.css.br +0 -0
  240. codex/static_root/assets/metadata-dialog-785c4cfc.css.gz +0 -0
  241. codex/static_root/assets/metadata-dialog-8b0e8aaa.d12b42b1c9da.js +0 -1
  242. codex/static_root/assets/metadata-dialog-8b0e8aaa.d12b42b1c9da.js.br +0 -0
  243. codex/static_root/assets/metadata-dialog-8b0e8aaa.d12b42b1c9da.js.gz +0 -0
  244. codex/static_root/assets/metadata-dialog-8b0e8aaa.js +0 -1
  245. codex/static_root/assets/metadata-dialog-8b0e8aaa.js.br +0 -0
  246. codex/static_root/assets/metadata-dialog-8b0e8aaa.js.gz +0 -0
  247. codex/static_root/assets/page-pdf-abfd509d.3870dab8eaf4.js.br +0 -0
  248. codex/static_root/assets/page-pdf-abfd509d.3870dab8eaf4.js.gz +0 -0
  249. codex/static_root/assets/page-pdf-abfd509d.js.br +0 -0
  250. codex/static_root/assets/page-pdf-abfd509d.js.gz +0 -0
  251. codex/static_root/assets/reader-a8b8f766.875abdd0d22e.css +0 -1
  252. codex/static_root/assets/reader-a8b8f766.875abdd0d22e.css.br +0 -0
  253. codex/static_root/assets/reader-a8b8f766.875abdd0d22e.css.gz +0 -0
  254. codex/static_root/assets/reader-a8b8f766.css +0 -1
  255. codex/static_root/assets/reader-a8b8f766.css.br +0 -0
  256. codex/static_root/assets/reader-a8b8f766.css.gz +0 -0
  257. codex/static_root/assets/reader-fe9345d2.759c31f82998.js +0 -1
  258. codex/static_root/assets/reader-fe9345d2.759c31f82998.js.br +0 -0
  259. codex/static_root/assets/reader-fe9345d2.759c31f82998.js.gz +0 -0
  260. codex/static_root/assets/reader-fe9345d2.js +0 -1
  261. codex/static_root/assets/reader-fe9345d2.js.br +0 -0
  262. codex/static_root/assets/reader-fe9345d2.js.gz +0 -0
  263. codex/static_root/js/choices-admin.3d958ea7f83b.json +0 -1
  264. codex/static_root/js/choices-admin.3d958ea7f83b.json.br +0 -0
  265. codex/static_root/js/choices-admin.3d958ea7f83b.json.gz +0 -0
  266. codex/static_root/js/choices.6bfc2a3d293f.json +0 -1
  267. codex/static_root/js/choices.6bfc2a3d293f.json.br +0 -0
  268. codex/static_root/js/choices.6bfc2a3d293f.json.gz +0 -0
  269. codex/static_root/manifest.64a989215af8.json.br +0 -0
  270. codex/static_root/manifest.64a989215af8.json.gz +0 -0
  271. codex/urls/opds/opensearch.py +0 -18
  272. codex/views/opds/v1/browser.py +0 -346
  273. codex/views/opds/v1/entry.py +0 -278
  274. codex/views/opds/v1/start.py +0 -28
  275. codex/views/opds/v1/util.py +0 -162
  276. codex/views/opds/v2/start.py +0 -28
  277. {codex-1.4.0a0.dist-info → codex-1.4.1.dist-info}/LICENSE +0 -0
  278. {codex-1.4.0a0.dist-info → codex-1.4.1.dist-info}/WHEEL +0 -0
  279. {codex-1.4.0a0.dist-info → codex-1.4.1.dist-info}/entry_points.txt +0 -0
@@ -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"
codex/views/opds/util.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """OPDS Utility classes."""
2
2
  from django.db.models import F
3
+ from django.http.response import HttpResponseRedirect
4
+ from django.urls import reverse
3
5
  from django.utils.http import urlencode
4
6
 
5
7
  from codex.models import (
@@ -13,6 +15,7 @@ from codex.models import (
13
15
  Tag,
14
16
  Team,
15
17
  )
18
+ from codex.serializers.choices import DEFAULTS
16
19
 
17
20
  OPDS_M2M_MODELS = (Character, Genre, Location, SeriesGroup, StoryArc, Tag, Team)
18
21
 
@@ -61,6 +64,39 @@ def get_m2m_objects(pk) -> dict:
61
64
  cats = {}
62
65
  for model in OPDS_M2M_MODELS:
63
66
  table = model.__name__.lower()
64
- qs = model.objects.filter(comic=pk).order_by("name").only("name")
67
+ rel = "comic"
68
+ if model == StoryArc:
69
+ rel = "storyarcnumber__" + rel
70
+ comic_filter = {rel: pk}
71
+ qs = model.objects.filter(**comic_filter).order_by("name").only("name")
65
72
  cats[table] = qs
73
+
66
74
  return cats
75
+
76
+
77
+ def full_redirect_view(url_name):
78
+ """Redirect to view, for a url name."""
79
+
80
+ def func(request):
81
+ """Redirect to view, forwarding query strings and auth."""
82
+ kwargs = DEFAULTS["route"]
83
+ url = reverse(url_name, kwargs=kwargs)
84
+
85
+ # Forward the query string.
86
+ path = request.get_full_path()
87
+ if path:
88
+ parts = path.split("?")
89
+ if len(parts) >= 2: # noqa PLR2004
90
+ parts[0] = url
91
+ url = "?".join(parts)
92
+
93
+ response = HttpResponseRedirect(url)
94
+
95
+ # Forward authorization.
96
+ auth_header = request.META.get("HTTP_AUTHORIZATION")
97
+ if auth_header:
98
+ response["HTTP_AUTHORIZATION"] = auth_header
99
+
100
+ return response
101
+
102
+ return func
@@ -1 +1 @@
1
- """OPDS 1 Views."""
1
+ """OPDS v1 Views."""
@@ -0,0 +1,21 @@
1
+ """OPDS v1 Data classes."""
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class OPDS1Link:
9
+ """An OPDS Link."""
10
+
11
+ rel: str
12
+ href: str
13
+ mime_type: str
14
+ title: str = ""
15
+ length: int = 0
16
+ facet_group: str = ""
17
+ facet_active: bool = False
18
+ thr_count: int = 0
19
+ pse_count: int = 0
20
+ pse_last_read: int = 0
21
+ pse_last_read_date: Optional[datetime] = None
@@ -0,0 +1 @@
1
+ """OPDS v1 Entries."""
@@ -0,0 +1,23 @@
1
+ """OPDS v1 Entry Data classes."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class OPDS1EntryObject:
7
+ """Fake entry db object for top link & facet entries."""
8
+
9
+ group: str = ""
10
+ pk: int = 0
11
+ name: str = ""
12
+ summary: str = ""
13
+ fake: bool = True
14
+
15
+
16
+ @dataclass
17
+ class OPDS1EntryData:
18
+ """Entry Data class to avoid to many args."""
19
+
20
+ acquisition_groups: frozenset
21
+ issue_max: int
22
+ metadata: bool
23
+ mime_type_map: dict
@@ -0,0 +1,151 @@
1
+ """OPDS v1 Entry."""
2
+ import json
3
+ from datetime import datetime, timezone
4
+ from urllib.parse import urlencode
5
+
6
+ from django.urls import reverse
7
+
8
+ from codex.logger.logging import get_logger
9
+ from codex.models import Comic
10
+ from codex.views.opds.const import (
11
+ AUTHOR_ROLES,
12
+ BLANK_TITLE,
13
+ )
14
+ from codex.views.opds.util import (
15
+ get_creator_people,
16
+ get_m2m_objects,
17
+ )
18
+ from codex.views.opds.v1.entry.links import OPDS1EntryLinksMixin
19
+
20
+ LOG = get_logger(__name__)
21
+
22
+
23
+ class OPDS1Entry(OPDS1EntryLinksMixin):
24
+ """An OPDS entry object."""
25
+
26
+ _DATE_FORMAT_BASE = "%Y-%m-%dT%H:%M:%S"
27
+ _DATE_FORMAT_MS = _DATE_FORMAT_BASE + ".%f%z"
28
+ _DATE_FORMAT = _DATE_FORMAT_BASE + "%z"
29
+ _DATE_FORMATS = (_DATE_FORMAT_MS, _DATE_FORMAT)
30
+
31
+ @property
32
+ def id_tag(self):
33
+ """GUID is a nav url."""
34
+ # Id top links by query params but not regular entries.
35
+ return self._nav_href(metadata=self.metadata)
36
+
37
+ @property
38
+ def title(self):
39
+ """Compute the item title."""
40
+ result = ""
41
+ try:
42
+ parts = []
43
+ if not self.fake:
44
+ group = self.obj.group
45
+ if group == "i":
46
+ parts.append(self.obj.publisher_name)
47
+ elif group == "v":
48
+ parts.append(self.obj.series_name)
49
+ elif group == "c":
50
+ title = Comic.get_title(self.obj, issue_max=self.issue_max)
51
+ parts.append(title)
52
+
53
+ if name := self.obj.name:
54
+ parts.append(name)
55
+
56
+ result = " ".join(filter(None, parts))
57
+ except Exception as exc:
58
+ LOG.exception(exc)
59
+
60
+ if not result:
61
+ result = BLANK_TITLE
62
+ return result
63
+
64
+ @property
65
+ def issued(self):
66
+ """Return the published date."""
67
+ if self.obj.group == "c":
68
+ return self.obj.date
69
+ return None
70
+
71
+ @property
72
+ def publisher(self):
73
+ """Return the publisher."""
74
+ return self.obj.publisher_name
75
+
76
+ def _get_datefield(self, key):
77
+ result = None
78
+ if not self.fake and (value := getattr(self.obj, key, None)):
79
+ for date_format in self._DATE_FORMATS:
80
+ try:
81
+ if isinstance(value, str):
82
+ result = datetime.strptime(value, date_format).astimezone(
83
+ timezone.utc
84
+ )
85
+ if isinstance(value, datetime):
86
+ result = value.astimezone(timezone.utc).strftime(date_format)
87
+ break
88
+ except ValueError:
89
+ pass
90
+ return result
91
+
92
+ @property
93
+ def updated(self):
94
+ """When the entry was last updated."""
95
+ return self._get_datefield("updated_at")
96
+
97
+ @property
98
+ def published(self):
99
+ """When the entry was created."""
100
+ return self._get_datefield("created_at")
101
+
102
+ @property
103
+ def language(self):
104
+ """Return the entry language."""
105
+ return self.obj.language
106
+
107
+ @property
108
+ def summary(self):
109
+ """Return a child count or comic summary."""
110
+ if self.obj.group == "c":
111
+ desc = self.obj.summary
112
+ else:
113
+ children = self.obj.child_count
114
+ desc = f"{children} issues"
115
+ return desc
116
+
117
+ @staticmethod
118
+ def _add_url_to_obj(objs, filter_key):
119
+ """Add filter urls to objects."""
120
+ kwargs = {"group": "s", "pk": 0, "page": 1}
121
+ url_base = reverse("opds:v1:feed", kwargs=kwargs)
122
+ result = []
123
+ for obj in objs:
124
+ qp = {"filters": json.dumps({filter_key: [obj.pk]})}
125
+ qp = urlencode(qp)
126
+ obj.url = url_base + "?" + qp
127
+ result.append(obj)
128
+ return result
129
+
130
+ @property
131
+ def authors(self):
132
+ """Get Author names."""
133
+ if not self.metadata:
134
+ return []
135
+ people = get_creator_people(self.obj.pk, AUTHOR_ROLES)
136
+ return self._add_url_to_obj(people, "creators")
137
+
138
+ @property
139
+ def contributors(self):
140
+ """Get Contributor names."""
141
+ if not self.metadata:
142
+ return []
143
+ people = get_creator_people(self.obj.pk, AUTHOR_ROLES, exclude=True)
144
+ return self._add_url_to_obj(people, "creators")
145
+
146
+ @property
147
+ def category_groups(self):
148
+ """Get Category labels."""
149
+ if not self.metadata:
150
+ return {}
151
+ return get_m2m_objects(self.obj.pk)
@@ -0,0 +1,135 @@
1
+ """OPDS v1 Entry Links Methods."""
2
+ from urllib.parse import quote_plus
3
+
4
+ from django.contrib.staticfiles.storage import staticfiles_storage
5
+ from django.urls import reverse
6
+
7
+ from codex.models import Comic
8
+ from codex.views.opds.const import MimeType, Rel
9
+ from codex.views.opds.util import update_href_query_params
10
+ from codex.views.opds.v1.data import OPDS1Link
11
+ from codex.views.opds.v1.entry.data import OPDS1EntryData, OPDS1EntryObject
12
+
13
+
14
+ class OPDS1EntryLinksMixin:
15
+ """OPDS v1 Entry Links Methods."""
16
+
17
+ def __init__(self, obj, query_params, data: OPDS1EntryData):
18
+ """Initialize params."""
19
+ self.obj = obj
20
+ self.fake = isinstance(self.obj, OPDS1EntryObject)
21
+ self.query_params = query_params
22
+ self.acquision_groups = data.acquisition_groups
23
+ self.issue_max = data.issue_max
24
+ self.metadata = data.metadata
25
+ self.mime_type_map = data.mime_type_map
26
+
27
+ def _thumb_link(self):
28
+ if self.fake:
29
+ return None
30
+ cover_pk = self.obj.cover_pk
31
+ if cover_pk:
32
+ kwargs = {"pk": cover_pk}
33
+ href = reverse("opds:bin:cover", kwargs=kwargs)
34
+ elif cover_pk == 0:
35
+ href = staticfiles_storage.url("img/missing_cover.webp")
36
+ else:
37
+ return None
38
+ return OPDS1Link(Rel.THUMBNAIL, href, "image/webp")
39
+
40
+ def _image_link(self):
41
+ if self.fake:
42
+ return None
43
+ cover_pk = self.obj.cover_pk
44
+ if cover_pk:
45
+ kwargs = {"pk": cover_pk, "page": 0}
46
+ href = reverse("opds:bin:page", kwargs=kwargs)
47
+ mime_type = "image/jpeg"
48
+ elif cover_pk == 0:
49
+ href = staticfiles_storage.url("img/missing_cover.webp")
50
+ mime_type = "image/webp"
51
+ else:
52
+ return None
53
+ return OPDS1Link(Rel.IMAGE, href, mime_type)
54
+
55
+ def _nav_href(self, metadata=False):
56
+ kwargs = {"group": self.obj.group, "pk": self.obj.pk, "page": 1}
57
+ href = reverse("opds:v1:feed", kwargs=kwargs)
58
+ qps = {}
59
+ if (
60
+ self.obj.group == "a"
61
+ and self.obj.pk
62
+ and not self.query_params.get("orderBy")
63
+ ):
64
+ # story arcs get ordered by story_arc_number by default
65
+ qps.update({"orderBy": "story_arc_number"})
66
+ if metadata:
67
+ qps.update({"opdsMetadata": 1})
68
+ return update_href_query_params(href, self.query_params, qps)
69
+
70
+ def _nav_link(self, metadata=False):
71
+ group = self.obj.group
72
+
73
+ if group in self.acquision_groups:
74
+ mime_type = MimeType.ENTRY_CATALOG if metadata else MimeType.ACQUISITION
75
+ else:
76
+ mime_type = MimeType.NAV
77
+
78
+ href = self._nav_href(metadata)
79
+ thr_count = 0 if self.fake else self.obj.child_count
80
+ rel = Rel.ALTERNATE if metadata else "subsection"
81
+
82
+ return OPDS1Link(rel, href, mime_type, thr_count=thr_count)
83
+
84
+ def _download_link(self):
85
+ pk = self.obj.pk
86
+ if not pk:
87
+ return None
88
+ fn = Comic.get_filename(self.obj)
89
+ fn = quote_plus(fn)
90
+ kwargs = {"pk": pk, "filename": fn}
91
+ href = reverse("opds:bin:download", kwargs=kwargs)
92
+ mime_type = self.mime_type_map.get(self.obj.file_type, MimeType.OCTET)
93
+ return OPDS1Link(Rel.ACQUISITION, href, mime_type, length=self.obj.size)
94
+
95
+ def _stream_link(self):
96
+ pk = self.obj.pk
97
+ if not pk:
98
+ return None
99
+ kwargs = {"pk": pk, "page": 0}
100
+ qps = {"bookmark": 1}
101
+ href = reverse("opds:bin:page", kwargs=kwargs)
102
+ href = update_href_query_params(href, {}, qps)
103
+ href = href.replace("0/page.jpg", "{pageNumber}/page.jpg")
104
+ page = self.obj.page
105
+ count = self.obj.page_count
106
+ bookmark_updated_at = self.obj.bookmark_updated_at
107
+ return OPDS1Link(
108
+ Rel.STREAM,
109
+ href,
110
+ MimeType.STREAM,
111
+ pse_count=count,
112
+ pse_last_read=page,
113
+ pse_last_read_date=bookmark_updated_at,
114
+ )
115
+
116
+ @property
117
+ def links(self):
118
+ """Create all entry links."""
119
+ result = []
120
+ if thumb := self._thumb_link():
121
+ result += [thumb]
122
+ if image := self._image_link():
123
+ result += [image]
124
+
125
+ if self.obj.group == "c" and not self.fake:
126
+ if download := self._download_link():
127
+ result += [download]
128
+ if stream := self._stream_link():
129
+ result += [stream]
130
+ if not self.metadata and (metadata := self._nav_link(metadata=True)):
131
+ result += [metadata]
132
+ elif nav := self._nav_link():
133
+ result += [nav]
134
+
135
+ return result