arthexis 0.1.22__py3-none-any.whl → 0.1.23__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 arthexis might be problematic. Click here for more details.

core/models.py CHANGED
@@ -2946,6 +2946,17 @@ class ClientReportSchedule(Entity):
2946
2946
  "application/json",
2947
2947
  )
2948
2948
  )
2949
+ pdf_path = export.get("pdf_path")
2950
+ if pdf_path:
2951
+ pdf_file = Path(settings.BASE_DIR) / pdf_path
2952
+ if pdf_file.exists():
2953
+ attachments.append(
2954
+ (
2955
+ pdf_file.name,
2956
+ pdf_file.read_bytes(),
2957
+ "application/pdf",
2958
+ )
2959
+ )
2949
2960
  subject = f"Client report {report.start_date} to {report.end_date}"
2950
2961
  body = (
2951
2962
  "Attached is the client report generated for the period "
@@ -3016,11 +3027,11 @@ class ClientReport(Entity):
3016
3027
  recipients: list[str] | None = None,
3017
3028
  disable_emails: bool = False,
3018
3029
  ):
3019
- rows = cls.build_rows(start_date, end_date)
3030
+ payload = cls.build_rows(start_date, end_date)
3020
3031
  return cls.objects.create(
3021
3032
  start_date=start_date,
3022
3033
  end_date=end_date,
3023
- data={"rows": rows, "schema": "session-list/v1"},
3034
+ data=payload,
3024
3035
  owner=owner,
3025
3036
  schedule=schedule,
3026
3037
  recipients=list(recipients or []),
@@ -3050,15 +3061,13 @@ class ClientReport(Entity):
3050
3061
  _json.dumps(self.data, indent=2, default=str), encoding="utf-8"
3051
3062
  )
3052
3063
 
3053
- def _relative(path: Path) -> str:
3054
- try:
3055
- return str(path.relative_to(base_dir))
3056
- except ValueError:
3057
- return str(path)
3064
+ pdf_path = report_dir / f"{identifier}.pdf"
3065
+ self.render_pdf(pdf_path)
3058
3066
 
3059
3067
  export = {
3060
- "html_path": _relative(html_path),
3061
- "json_path": _relative(json_path),
3068
+ "html_path": ClientReport._relative_to_base(html_path, base_dir),
3069
+ "json_path": ClientReport._relative_to_base(json_path, base_dir),
3070
+ "pdf_path": ClientReport._relative_to_base(pdf_path, base_dir),
3062
3071
  }
3063
3072
 
3064
3073
  updated = dict(self.data)
@@ -3069,28 +3078,32 @@ class ClientReport(Entity):
3069
3078
 
3070
3079
  @staticmethod
3071
3080
  def build_rows(start_date=None, end_date=None, *, for_display: bool = False):
3072
- from ocpp.models import Transaction
3081
+ dataset = ClientReport._build_dataset(start_date, end_date)
3082
+ if for_display:
3083
+ return ClientReport._normalize_dataset_for_display(dataset)
3084
+ return dataset
3073
3085
 
3074
- qs = Transaction.objects.filter(
3075
- (Q(rfid__isnull=False) & ~Q(rfid=""))
3076
- | (Q(vid__isnull=False) & ~Q(vid=""))
3077
- )
3078
- if start_date:
3079
- from datetime import datetime, time, timedelta, timezone as pytimezone
3086
+ @staticmethod
3087
+ def _build_dataset(start_date=None, end_date=None):
3088
+ from datetime import datetime, time, timedelta, timezone as pytimezone
3089
+ from ocpp.models import Charger, Transaction
3080
3090
 
3091
+ qs = Transaction.objects.all()
3092
+
3093
+ start_dt = None
3094
+ end_dt = None
3095
+ if start_date:
3081
3096
  start_dt = datetime.combine(start_date, time.min, tzinfo=pytimezone.utc)
3082
3097
  qs = qs.filter(start_time__gte=start_dt)
3083
3098
  if end_date:
3084
- from datetime import datetime, time, timedelta, timezone as pytimezone
3085
-
3086
3099
  end_dt = datetime.combine(
3087
3100
  end_date + timedelta(days=1), time.min, tzinfo=pytimezone.utc
3088
3101
  )
3089
3102
  qs = qs.filter(start_time__lt=end_dt)
3090
3103
 
3091
- transactions = list(
3092
- qs.select_related("account").order_by("-start_time", "-pk")
3093
- )
3104
+ qs = qs.select_related("account", "charger").prefetch_related("meter_values")
3105
+ transactions = list(qs.order_by("start_time", "pk"))
3106
+
3094
3107
  rfid_values = {tx.rfid for tx in transactions if tx.rfid}
3095
3108
  tag_map: dict[str, RFID] = {}
3096
3109
  if rfid_values:
@@ -3101,52 +3114,227 @@ class ClientReport(Entity):
3101
3114
  )
3102
3115
  }
3103
3116
 
3104
- rows: list[dict[str, Any]] = []
3117
+ charger_ids = {
3118
+ tx.charger.charger_id
3119
+ for tx in transactions
3120
+ if getattr(tx, "charger", None) and tx.charger.charger_id
3121
+ }
3122
+ aggregator_map: dict[str, Charger] = {}
3123
+ if charger_ids:
3124
+ aggregator_map = {
3125
+ charger.charger_id: charger
3126
+ for charger in Charger.objects.filter(
3127
+ charger_id__in=charger_ids, connector_id__isnull=True
3128
+ )
3129
+ }
3130
+
3131
+ groups: dict[str, dict[str, Any]] = {}
3105
3132
  for tx in transactions:
3106
- energy = tx.kw
3107
- if energy <= 0:
3133
+ charger = getattr(tx, "charger", None)
3134
+ if charger is None:
3108
3135
  continue
3136
+ base_id = charger.charger_id
3137
+ aggregator = aggregator_map.get(base_id) or charger
3138
+ entry = groups.setdefault(
3139
+ base_id,
3140
+ {"charger": aggregator, "transactions": []},
3141
+ )
3142
+ entry["transactions"].append(tx)
3143
+
3144
+ evcs_entries: list[dict[str, Any]] = []
3145
+ total_all_time = 0.0
3146
+ total_period = 0.0
3147
+
3148
+ def _sort_key(tx):
3149
+ anchor = getattr(tx, "start_time", None)
3150
+ if anchor is None:
3151
+ anchor = datetime.min.replace(tzinfo=pytimezone.utc)
3152
+ return (anchor, tx.pk or 0)
3153
+
3154
+ for base_id, info in sorted(groups.items(), key=lambda item: item[0]):
3155
+ aggregator = info["charger"]
3156
+ txs = sorted(info["transactions"], key=_sort_key)
3157
+ total_kw_all = float(getattr(aggregator, "total_kw", 0.0) or 0.0)
3158
+ total_kw_period = 0.0
3159
+ if hasattr(aggregator, "total_kw_for_range"):
3160
+ total_kw_period = float(
3161
+ aggregator.total_kw_for_range(start=start_dt, end=end_dt) or 0.0
3162
+ )
3163
+ total_all_time += total_kw_all
3164
+ total_period += total_kw_period
3109
3165
 
3110
- subject = None
3111
- if tx.account and getattr(tx.account, "name", None):
3112
- subject = tx.account.name
3113
- else:
3114
- tag = tag_map.get(tx.rfid)
3115
- if tag:
3116
- account = next(iter(tag.energy_accounts.all()), None)
3117
- if account:
3118
- subject = account.name
3119
- else:
3120
- subject = str(tag.label_id)
3166
+ session_rows: list[dict[str, Any]] = []
3167
+ for tx in txs:
3168
+ session_kw = float(getattr(tx, "kw", 0.0) or 0.0)
3169
+ if session_kw <= 0:
3170
+ continue
3121
3171
 
3122
- if subject is None:
3123
- subject = tx.rfid or tx.vid
3172
+ start_kwh, end_kwh = ClientReport._resolve_meter_bounds(tx)
3124
3173
 
3125
- start_value = tx.start_time
3126
- end_value = tx.stop_time
3127
- if not for_display:
3128
- start_value = start_value.isoformat()
3129
- end_value = end_value.isoformat() if end_value else None
3174
+ connector_number = (
3175
+ tx.connector_id
3176
+ if getattr(tx, "connector_id", None) is not None
3177
+ else getattr(getattr(tx, "charger", None), "connector_id", None)
3178
+ )
3130
3179
 
3131
- rows.append(
3180
+ rfid_value = (tx.rfid or "").strip()
3181
+ tag = tag_map.get(rfid_value)
3182
+ label = None
3183
+ account_name = (
3184
+ tx.account.name
3185
+ if tx.account and getattr(tx.account, "name", None)
3186
+ else None
3187
+ )
3188
+ if tag:
3189
+ label = tag.custom_label or str(tag.label_id)
3190
+ if not account_name:
3191
+ account = next(iter(tag.energy_accounts.all()), None)
3192
+ if account and getattr(account, "name", None):
3193
+ account_name = account.name
3194
+ elif rfid_value:
3195
+ label = rfid_value
3196
+
3197
+ session_rows.append(
3198
+ {
3199
+ "connector": connector_number,
3200
+ "rfid_label": label,
3201
+ "account_name": account_name,
3202
+ "start_kwh": start_kwh,
3203
+ "end_kwh": end_kwh,
3204
+ "session_kwh": session_kw,
3205
+ "start": tx.start_time.isoformat()
3206
+ if getattr(tx, "start_time", None)
3207
+ else None,
3208
+ "end": tx.stop_time.isoformat()
3209
+ if getattr(tx, "stop_time", None)
3210
+ else None,
3211
+ }
3212
+ )
3213
+
3214
+ evcs_entries.append(
3132
3215
  {
3133
- "subject": subject,
3134
- "rfid": tx.rfid,
3135
- "vid": tx.vid,
3136
- "kw": energy,
3137
- "start": start_value,
3138
- "end": end_value,
3216
+ "charger_id": aggregator.pk,
3217
+ "serial_number": aggregator.charger_id,
3218
+ "display_name": aggregator.display_name
3219
+ or aggregator.name
3220
+ or aggregator.charger_id,
3221
+ "total_kw": total_kw_all,
3222
+ "total_kw_period": total_kw_period,
3223
+ "transactions": session_rows,
3139
3224
  }
3140
3225
  )
3141
3226
 
3142
- return rows
3227
+ return {
3228
+ "schema": "evcs-session/v1",
3229
+ "evcs": evcs_entries,
3230
+ "totals": {
3231
+ "total_kw": total_all_time,
3232
+ "total_kw_period": total_period,
3233
+ },
3234
+ }
3143
3235
 
3144
- @property
3145
- def rows_for_display(self):
3146
- rows = self.data.get("rows", [])
3147
- if self.data.get("schema") == "session-list/v1":
3236
+ @staticmethod
3237
+ def _resolve_meter_bounds(tx) -> tuple[float | None, float | None]:
3238
+ def _convert(value):
3239
+ if value in {None, ""}:
3240
+ return None
3241
+ try:
3242
+ return float(value) / 1000.0
3243
+ except (TypeError, ValueError):
3244
+ return None
3245
+
3246
+ start_value = _convert(getattr(tx, "meter_start", None))
3247
+ end_value = _convert(getattr(tx, "meter_stop", None))
3248
+
3249
+ readings_manager = getattr(tx, "meter_values", None)
3250
+ readings = []
3251
+ if readings_manager is not None:
3252
+ readings = [
3253
+ reading
3254
+ for reading in readings_manager.all()
3255
+ if getattr(reading, "energy", None) is not None
3256
+ ]
3257
+ if readings:
3258
+ readings.sort(key=lambda item: item.timestamp)
3259
+ if start_value is None:
3260
+ start_value = float(readings[0].energy or 0)
3261
+ if end_value is None:
3262
+ end_value = float(readings[-1].energy or 0)
3263
+
3264
+ return start_value, end_value
3265
+
3266
+ @staticmethod
3267
+ def _normalize_dataset_for_display(dataset: dict[str, Any]):
3268
+ schema = dataset.get("schema")
3269
+ if schema == "evcs-session/v1":
3270
+ from datetime import datetime
3271
+
3272
+ evcs_entries: list[dict[str, Any]] = []
3273
+ for entry in dataset.get("evcs", []):
3274
+ normalized_rows: list[dict[str, Any]] = []
3275
+ for row in entry.get("transactions", []):
3276
+ start_val = row.get("start")
3277
+ end_val = row.get("end")
3278
+
3279
+ start_dt = None
3280
+ if start_val:
3281
+ start_dt = parse_datetime(start_val)
3282
+ if start_dt and timezone.is_naive(start_dt):
3283
+ start_dt = timezone.make_aware(start_dt, timezone.utc)
3284
+
3285
+ end_dt = None
3286
+ if end_val:
3287
+ end_dt = parse_datetime(end_val)
3288
+ if end_dt and timezone.is_naive(end_dt):
3289
+ end_dt = timezone.make_aware(end_dt, timezone.utc)
3290
+
3291
+ normalized_rows.append(
3292
+ {
3293
+ "connector": row.get("connector"),
3294
+ "rfid_label": row.get("rfid_label"),
3295
+ "account_name": row.get("account_name"),
3296
+ "start_kwh": row.get("start_kwh"),
3297
+ "end_kwh": row.get("end_kwh"),
3298
+ "session_kwh": row.get("session_kwh"),
3299
+ "start": start_dt,
3300
+ "end": end_dt,
3301
+ }
3302
+ )
3303
+
3304
+ normalized_rows.sort(
3305
+ key=lambda item: (
3306
+ item["start"]
3307
+ if item["start"] is not None
3308
+ else datetime.min.replace(tzinfo=timezone.utc),
3309
+ item.get("connector") or 0,
3310
+ )
3311
+ )
3312
+
3313
+ evcs_entries.append(
3314
+ {
3315
+ "display_name": entry.get("display_name")
3316
+ or entry.get("serial_number")
3317
+ or "Charge Point",
3318
+ "serial_number": entry.get("serial_number"),
3319
+ "total_kw": entry.get("total_kw", 0.0),
3320
+ "total_kw_period": entry.get("total_kw_period", 0.0),
3321
+ "transactions": normalized_rows,
3322
+ }
3323
+ )
3324
+
3325
+ totals = dataset.get("totals", {})
3326
+ return {
3327
+ "schema": schema,
3328
+ "evcs": evcs_entries,
3329
+ "totals": {
3330
+ "total_kw": totals.get("total_kw", 0.0),
3331
+ "total_kw_period": totals.get("total_kw_period", 0.0),
3332
+ },
3333
+ }
3334
+
3335
+ if schema == "session-list/v1":
3148
3336
  parsed: list[dict[str, Any]] = []
3149
- for row in rows:
3337
+ for row in dataset.get("rows", []):
3150
3338
  item = dict(row)
3151
3339
  start_val = row.get("start")
3152
3340
  end_val = row.get("end")
@@ -3168,8 +3356,216 @@ class ClientReport(Entity):
3168
3356
  item["end"] = None
3169
3357
 
3170
3358
  parsed.append(item)
3171
- return parsed
3172
- return rows
3359
+
3360
+ return {"schema": schema, "rows": parsed}
3361
+
3362
+ return {"schema": schema, "rows": dataset.get("rows", [])}
3363
+
3364
+ @property
3365
+ def rows_for_display(self):
3366
+ data = self.data or {}
3367
+ return ClientReport._normalize_dataset_for_display(data)
3368
+
3369
+ @staticmethod
3370
+ def _relative_to_base(path: Path, base_dir: Path) -> str:
3371
+ try:
3372
+ return str(path.relative_to(base_dir))
3373
+ except ValueError:
3374
+ return str(path)
3375
+
3376
+ def render_pdf(self, target: Path):
3377
+ from reportlab.lib import colors
3378
+ from reportlab.lib.pagesizes import landscape, letter
3379
+ from reportlab.lib.styles import getSampleStyleSheet
3380
+ from reportlab.lib.units import inch
3381
+ from reportlab.platypus import (
3382
+ Paragraph,
3383
+ SimpleDocTemplate,
3384
+ Spacer,
3385
+ Table,
3386
+ TableStyle,
3387
+ )
3388
+
3389
+ target_path = Path(target)
3390
+ target_path.parent.mkdir(parents=True, exist_ok=True)
3391
+
3392
+ dataset = self.rows_for_display
3393
+ schema = dataset.get("schema")
3394
+
3395
+ styles = getSampleStyleSheet()
3396
+ title_style = styles["Title"]
3397
+ subtitle_style = styles["Heading2"]
3398
+ normal_style = styles["BodyText"]
3399
+ emphasis_style = styles["Heading3"]
3400
+
3401
+ document = SimpleDocTemplate(
3402
+ str(target_path),
3403
+ pagesize=landscape(letter),
3404
+ leftMargin=0.5 * inch,
3405
+ rightMargin=0.5 * inch,
3406
+ topMargin=0.6 * inch,
3407
+ bottomMargin=0.5 * inch,
3408
+ )
3409
+
3410
+ story: list = []
3411
+ story.append(Paragraph("Consumer Report", title_style))
3412
+ story.append(
3413
+ Paragraph(
3414
+ f"Period: {self.start_date} to {self.end_date}",
3415
+ emphasis_style,
3416
+ )
3417
+ )
3418
+ story.append(Spacer(1, 0.25 * inch))
3419
+
3420
+ if schema == "evcs-session/v1":
3421
+ evcs_entries = dataset.get("evcs", [])
3422
+ if not evcs_entries:
3423
+ story.append(
3424
+ Paragraph(
3425
+ "No charging sessions recorded for the selected period.",
3426
+ normal_style,
3427
+ )
3428
+ )
3429
+ for index, evcs in enumerate(evcs_entries):
3430
+ if index:
3431
+ story.append(Spacer(1, 0.2 * inch))
3432
+
3433
+ display_name = evcs.get("display_name") or "Charge Point"
3434
+ serial_number = evcs.get("serial_number")
3435
+ header_text = display_name
3436
+ if serial_number:
3437
+ header_text = f"{display_name} (Serial: {serial_number})"
3438
+ story.append(Paragraph(header_text, subtitle_style))
3439
+
3440
+ metrics_text = (
3441
+ f"Total kW (all time): {evcs.get('total_kw', 0.0):.2f} | "
3442
+ f"Total kW (period): {evcs.get('total_kw_period', 0.0):.2f}"
3443
+ )
3444
+ story.append(Paragraph(metrics_text, normal_style))
3445
+ story.append(Spacer(1, 0.1 * inch))
3446
+
3447
+ transactions = evcs.get("transactions", [])
3448
+ if transactions:
3449
+ table_data = [
3450
+ [
3451
+ "Connector",
3452
+ "Start kWh",
3453
+ "End kWh",
3454
+ "Session kWh",
3455
+ "Start Time",
3456
+ "End Time",
3457
+ "RFID Label",
3458
+ "Account",
3459
+ ]
3460
+ ]
3461
+
3462
+ def _format_number(value):
3463
+ return f"{value:.2f}" if value is not None else "—"
3464
+
3465
+ for row in transactions:
3466
+ start_dt = row.get("start")
3467
+ end_dt = row.get("end")
3468
+ start_display = (
3469
+ timezone.localtime(start_dt).strftime("%Y-%m-%d %H:%M")
3470
+ if start_dt
3471
+ else "—"
3472
+ )
3473
+ end_display = (
3474
+ timezone.localtime(end_dt).strftime("%Y-%m-%d %H:%M")
3475
+ if end_dt
3476
+ else "—"
3477
+ )
3478
+
3479
+ table_data.append(
3480
+ [
3481
+ row.get("connector")
3482
+ if row.get("connector") is not None
3483
+ else "—",
3484
+ _format_number(row.get("start_kwh")),
3485
+ _format_number(row.get("end_kwh")),
3486
+ _format_number(row.get("session_kwh")),
3487
+ start_display,
3488
+ end_display,
3489
+ row.get("rfid_label") or "—",
3490
+ row.get("account_name") or "—",
3491
+ ]
3492
+ )
3493
+
3494
+ table = Table(table_data, repeatRows=1)
3495
+ table.setStyle(
3496
+ TableStyle(
3497
+ [
3498
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0f172a")),
3499
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
3500
+ ("ALIGN", (0, 0), (-1, 0), "CENTER"),
3501
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
3502
+ ("FONTSIZE", (0, 0), (-1, 0), 9),
3503
+ (
3504
+ "ROWBACKGROUNDS",
3505
+ (0, 1),
3506
+ (-1, -1),
3507
+ [colors.whitesmoke, colors.HexColor("#eef2ff")],
3508
+ ),
3509
+ ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
3510
+ ("VALIGN", (0, 1), (-1, -1), "MIDDLE"),
3511
+ ]
3512
+ )
3513
+ )
3514
+ story.append(table)
3515
+ else:
3516
+ story.append(
3517
+ Paragraph(
3518
+ "No charging sessions recorded for this charge point.",
3519
+ normal_style,
3520
+ )
3521
+ )
3522
+ else:
3523
+ story.append(
3524
+ Paragraph(
3525
+ "No structured data is available for this report.",
3526
+ normal_style,
3527
+ )
3528
+ )
3529
+
3530
+ totals = dataset.get("totals") or {}
3531
+ story.append(Spacer(1, 0.3 * inch))
3532
+ story.append(
3533
+ Paragraph(
3534
+ f"Report totals — Total kW (all time): {totals.get('total_kw', 0.0):.2f}",
3535
+ emphasis_style,
3536
+ )
3537
+ )
3538
+ story.append(
3539
+ Paragraph(
3540
+ f"Total kW during period: {totals.get('total_kw_period', 0.0):.2f}",
3541
+ emphasis_style,
3542
+ )
3543
+ )
3544
+
3545
+ document.build(story)
3546
+
3547
+ def ensure_pdf(self) -> Path:
3548
+ base_dir = Path(settings.BASE_DIR)
3549
+ export = dict((self.data or {}).get("export") or {})
3550
+ pdf_relative = export.get("pdf_path")
3551
+ if pdf_relative:
3552
+ candidate = base_dir / pdf_relative
3553
+ if candidate.exists():
3554
+ return candidate
3555
+
3556
+ report_dir = base_dir / "work" / "reports"
3557
+ report_dir.mkdir(parents=True, exist_ok=True)
3558
+ timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
3559
+ identifier = f"client_report_{self.pk}_{timestamp}"
3560
+ pdf_path = report_dir / f"{identifier}.pdf"
3561
+ self.render_pdf(pdf_path)
3562
+
3563
+ export["pdf_path"] = ClientReport._relative_to_base(pdf_path, base_dir)
3564
+ updated = dict(self.data)
3565
+ updated["export"] = export
3566
+ type(self).objects.filter(pk=self.pk).update(data=updated)
3567
+ self.data = updated
3568
+ return pdf_path
3173
3569
 
3174
3570
 
3175
3571
  class BrandManager(EntityManager):
@@ -3622,6 +4018,10 @@ class PackageRelease(Entity):
3622
4018
  if old_name not in expected and old_path.exists():
3623
4019
  old_path.unlink()
3624
4020
 
4021
+ def delete(self, using=None, keep_parents=False):
4022
+ user_data.delete_user_fixture(self)
4023
+ super().delete(using=using, keep_parents=keep_parents)
4024
+
3625
4025
  def __str__(self) -> str: # pragma: no cover - trivial
3626
4026
  return f"{self.package.name} {self.version}"
3627
4027
 
@@ -3629,10 +4029,27 @@ class PackageRelease(Entity):
3629
4029
  """Return a :class:`ReleasePackage` built from the package."""
3630
4030
  return self.package.to_package()
3631
4031
 
3632
- def to_credentials(self) -> Credentials | None:
3633
- """Return :class:`Credentials` from the associated release manager."""
3634
- manager = self.release_manager or self.package.release_manager
3635
- if manager:
4032
+ def to_credentials(
4033
+ self, user: models.Model | None = None
4034
+ ) -> Credentials | None:
4035
+ """Return :class:`Credentials` from available release managers."""
4036
+
4037
+ manager_candidates: list[ReleaseManager] = []
4038
+
4039
+ for candidate in (self.release_manager, self.package.release_manager):
4040
+ if candidate and candidate not in manager_candidates:
4041
+ manager_candidates.append(candidate)
4042
+
4043
+ if user is not None and getattr(user, "is_authenticated", False):
4044
+ try:
4045
+ user_manager = ReleaseManager.objects.get(user=user)
4046
+ except ReleaseManager.DoesNotExist:
4047
+ user_manager = None
4048
+ else:
4049
+ if user_manager not in manager_candidates:
4050
+ manager_candidates.append(user_manager)
4051
+
4052
+ for manager in manager_candidates:
3636
4053
  creds = manager.to_credentials()
3637
4054
  if creds and creds.has_auth():
3638
4055
  return creds
@@ -3654,7 +4071,9 @@ class PackageRelease(Entity):
3654
4071
  return manager.github_token
3655
4072
  return os.environ.get("GITHUB_TOKEN")
3656
4073
 
3657
- def build_publish_targets(self) -> list[RepositoryTarget]:
4074
+ def build_publish_targets(
4075
+ self, user: models.Model | None = None
4076
+ ) -> list[RepositoryTarget]:
3658
4077
  """Return repository targets for publishing this release."""
3659
4078
 
3660
4079
  manager = self.release_manager or self.package.release_manager
@@ -3663,7 +4082,7 @@ class PackageRelease(Entity):
3663
4082
  env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
3664
4083
  primary_url = env_primary.strip()
3665
4084
 
3666
- primary_creds = self.to_credentials()
4085
+ primary_creds = self.to_credentials(user=user)
3667
4086
  targets.append(
3668
4087
  RepositoryTarget(
3669
4088
  name="PyPI",
@@ -3805,6 +4224,8 @@ class PackageRelease(Entity):
3805
4224
  """
3806
4225
 
3807
4226
  version = (version or "").strip()
4227
+ if version.endswith("+"):
4228
+ version = version.rstrip("+")
3808
4229
  revision = (revision or "").strip()
3809
4230
  if not version or not revision:
3810
4231
  return True
core/release.py CHANGED
@@ -520,11 +520,6 @@ def build(
520
520
  if not version_path.exists():
521
521
  raise ReleaseError("VERSION file not found")
522
522
  version = version_path.read_text().strip()
523
- if bump:
524
- major, minor, patch = map(int, version.split("."))
525
- patch += 1
526
- version = f"{major}.{minor}.{patch}"
527
- version_path.write_text(version + "\n")
528
523
  else:
529
524
  # Ensure the VERSION file reflects the provided release version
530
525
  if version_path.parent != Path("."):