circle-so-cli 0.1.0__tar.gz
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.
- circle_so_cli-0.1.0/.github/workflows/ci.yml +21 -0
- circle_so_cli-0.1.0/.github/workflows/publish.yml +21 -0
- circle_so_cli-0.1.0/.gitignore +11 -0
- circle_so_cli-0.1.0/LICENSE +21 -0
- circle_so_cli-0.1.0/PKG-INFO +79 -0
- circle_so_cli-0.1.0/README.md +55 -0
- circle_so_cli-0.1.0/docs/ROADMAP.md +58 -0
- circle_so_cli-0.1.0/pyproject.toml +48 -0
- circle_so_cli-0.1.0/src/circle_so/__init__.py +2 -0
- circle_so_cli-0.1.0/src/circle_so/cli.py +51 -0
- circle_so_cli-0.1.0/src/circle_so/commands/__init__.py +1 -0
- circle_so_cli-0.1.0/src/circle_so/commands/members.py +155 -0
- circle_so_cli-0.1.0/src/circle_so/commands/moderators.py +114 -0
- circle_so_cli-0.1.0/src/circle_so/commands/report.py +94 -0
- circle_so_cli-0.1.0/src/circle_so/commands/spaces.py +91 -0
- circle_so_cli-0.1.0/src/circle_so/config.py +29 -0
- circle_so_cli-0.1.0/src/circle_so/db/__init__.py +1 -0
- circle_so_cli-0.1.0/src/circle_so/db/connection.py +109 -0
- circle_so_cli-0.1.0/tests/__init__.py +28 -0
- circle_so_cli-0.1.0/tests/test_cli.py +139 -0
- circle_so_cli-0.1.0/tests/test_config.py +27 -0
- circle_so_cli-0.1.0/tests/test_db.py +33 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e .[dev]
|
|
21
|
+
- run: pytest tests/ -v
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
- run: pip install build
|
|
18
|
+
- run: python -m build
|
|
19
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
20
|
+
with:
|
|
21
|
+
attestations: false
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damilola Afolabi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: circle-so-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI toolkit for managing Circle.so communities at scale
|
|
5
|
+
Project-URL: Homepage, https://github.com/boiyelove/circle-so
|
|
6
|
+
Project-URL: Repository, https://github.com/boiyelove/circle-so
|
|
7
|
+
Author: Damilola Afolabi
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: circle,circle.so,cli,community,management
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: circle-so-python-sdk>=0.1.0
|
|
18
|
+
Requires-Dist: click>=8.0
|
|
19
|
+
Requires-Dist: python-dotenv>=1.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# circle-so
|
|
26
|
+
|
|
27
|
+
CLI toolkit for managing Circle.so communities at scale.
|
|
28
|
+
|
|
29
|
+
Built on top of [circle-so-python-sdk](https://github.com/boiyelove/circle-so-python-sdk).
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install circle-so
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export CIRCLE_API_TOKEN="your_token"
|
|
41
|
+
|
|
42
|
+
# Spaces
|
|
43
|
+
circle-so spaces list --prefix kcna
|
|
44
|
+
circle-so spaces search "KCNA 048"
|
|
45
|
+
circle-so spaces lock --prefix kcna
|
|
46
|
+
circle-so spaces rename 1761784 --name "KCNA 072" --slug "kcna-072"
|
|
47
|
+
|
|
48
|
+
# Members
|
|
49
|
+
circle-so members import learners.csv
|
|
50
|
+
circle-so members audit --prefix kcna --cache
|
|
51
|
+
circle-so members add learners.csv --space "KCNA 048"
|
|
52
|
+
circle-so members fix-missing --dry-run
|
|
53
|
+
circle-so members move --from "KCNA 046" --to "KCNA 073" --max 100
|
|
54
|
+
|
|
55
|
+
# Moderators
|
|
56
|
+
circle-so moderators verify moderators.csv
|
|
57
|
+
circle-so moderators add moderators.csv
|
|
58
|
+
|
|
59
|
+
# Reports
|
|
60
|
+
circle-so report counts --prefix kcna
|
|
61
|
+
circle-so report inactive
|
|
62
|
+
circle-so report missing
|
|
63
|
+
circle-so report export moves
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
Set via environment variables or `.env` file:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
CIRCLE_API_TOKEN=your_token
|
|
72
|
+
CIRCLE_COMMUNITY_URL=https://your-community.circle.so
|
|
73
|
+
CIRCLE_SO_DB=./circle-so.db
|
|
74
|
+
CIRCLE_SO_DATA_DIR=~/Documents/Andela-K8s
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# circle-so
|
|
2
|
+
|
|
3
|
+
CLI toolkit for managing Circle.so communities at scale.
|
|
4
|
+
|
|
5
|
+
Built on top of [circle-so-python-sdk](https://github.com/boiyelove/circle-so-python-sdk).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install circle-so
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export CIRCLE_API_TOKEN="your_token"
|
|
17
|
+
|
|
18
|
+
# Spaces
|
|
19
|
+
circle-so spaces list --prefix kcna
|
|
20
|
+
circle-so spaces search "KCNA 048"
|
|
21
|
+
circle-so spaces lock --prefix kcna
|
|
22
|
+
circle-so spaces rename 1761784 --name "KCNA 072" --slug "kcna-072"
|
|
23
|
+
|
|
24
|
+
# Members
|
|
25
|
+
circle-so members import learners.csv
|
|
26
|
+
circle-so members audit --prefix kcna --cache
|
|
27
|
+
circle-so members add learners.csv --space "KCNA 048"
|
|
28
|
+
circle-so members fix-missing --dry-run
|
|
29
|
+
circle-so members move --from "KCNA 046" --to "KCNA 073" --max 100
|
|
30
|
+
|
|
31
|
+
# Moderators
|
|
32
|
+
circle-so moderators verify moderators.csv
|
|
33
|
+
circle-so moderators add moderators.csv
|
|
34
|
+
|
|
35
|
+
# Reports
|
|
36
|
+
circle-so report counts --prefix kcna
|
|
37
|
+
circle-so report inactive
|
|
38
|
+
circle-so report missing
|
|
39
|
+
circle-so report export moves
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Set via environment variables or `.env` file:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
CIRCLE_API_TOKEN=your_token
|
|
48
|
+
CIRCLE_COMMUNITY_URL=https://your-community.circle.so
|
|
49
|
+
CIRCLE_SO_DB=./circle-so.db
|
|
50
|
+
CIRCLE_SO_DATA_DIR=~/Documents/Andela-K8s
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
## Phase 1: Core CLI (Current)
|
|
4
|
+
- [x] Project scaffolding
|
|
5
|
+
- [ ] CLI framework with click
|
|
6
|
+
- [ ] Config management (env vars, .env, flags)
|
|
7
|
+
- [ ] DB schema with versioned migrations
|
|
8
|
+
- [ ] `spaces` commands: list, search, rename, lock/unlock
|
|
9
|
+
- [ ] `members` commands: import, audit, add, fix-missing
|
|
10
|
+
- [ ] `moderators` commands: verify, add
|
|
11
|
+
- [ ] `report` commands: counts, inactive, missing, export
|
|
12
|
+
|
|
13
|
+
## Phase 2: Caching Layer
|
|
14
|
+
- [ ] `--cache` flag on read commands
|
|
15
|
+
- [ ] TTL-based cache in SQLite (default 1 hour)
|
|
16
|
+
- [ ] `--refresh` flag to force fresh fetch
|
|
17
|
+
- [ ] Cache invalidation on write operations
|
|
18
|
+
- [ ] Cache stats command (`circle-so cache stats`, `circle-so cache clear`)
|
|
19
|
+
|
|
20
|
+
## Phase 3: Member Movement
|
|
21
|
+
- [ ] `members move` with engagement detection (posts, comments)
|
|
22
|
+
- [ ] Move planning with local preview (zero API calls)
|
|
23
|
+
- [ ] Move execution with verification
|
|
24
|
+
- [ ] Rollback support for failed moves
|
|
25
|
+
- [ ] Move history and audit trail in DB
|
|
26
|
+
|
|
27
|
+
## Phase 4: Bulk Operations
|
|
28
|
+
- [ ] Batch member invitations with progress bar
|
|
29
|
+
- [ ] Batch space creation from CSV/template
|
|
30
|
+
- [ ] Batch moderator assignment (when Circle API supports per-space moderators)
|
|
31
|
+
- [ ] Rate limit awareness with ETA display
|
|
32
|
+
|
|
33
|
+
## Phase 5: Observability
|
|
34
|
+
- [ ] `--verbose` and `--debug` flags on all commands
|
|
35
|
+
- [ ] Structured JSON logging option
|
|
36
|
+
- [ ] Operation audit log in DB (who did what, when)
|
|
37
|
+
- [ ] Diff reports (before/after state comparison)
|
|
38
|
+
|
|
39
|
+
## Phase 6: Multi-Community
|
|
40
|
+
- [ ] Named profiles (`circle-so --profile staging`)
|
|
41
|
+
- [ ] Profile management (`circle-so profile add/list/switch`)
|
|
42
|
+
- [ ] Per-profile DB and cache isolation
|
|
43
|
+
|
|
44
|
+
## Phase 7: Advanced Reports
|
|
45
|
+
- [ ] Member engagement scoring per space
|
|
46
|
+
- [ ] Inactive member detection with configurable thresholds
|
|
47
|
+
- [ ] Space health dashboard (member count, activity, moderator coverage)
|
|
48
|
+
- [ ] CSV/JSON/Markdown export formats
|
|
49
|
+
|
|
50
|
+
## Phase 8: Plugin System
|
|
51
|
+
- [ ] Hook system for pre/post command execution
|
|
52
|
+
- [ ] Custom command registration
|
|
53
|
+
- [ ] Webhook receiver for real-time sync
|
|
54
|
+
|
|
55
|
+
## Non-Goals
|
|
56
|
+
- Replacing the Circle.so web UI for day-to-day operations
|
|
57
|
+
- Real-time chat or notification management
|
|
58
|
+
- Content creation or moderation workflows
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "circle-so-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI toolkit for managing Circle.so communities at scale"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Damilola Afolabi" }]
|
|
13
|
+
keywords = ["circle", "circle.so", "community", "cli", "management"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"circle-so-python-sdk>=0.1.0",
|
|
23
|
+
"click>=8.0",
|
|
24
|
+
"python-dotenv>=1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.0",
|
|
30
|
+
"ruff>=0.1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
circle-so = "circle_so.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/boiyelove/circle-so"
|
|
38
|
+
Repository = "https://github.com/boiyelove/circle-so"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/circle_so"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
target-version = "py39"
|
|
45
|
+
line-length = 120
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Main CLI entry point."""
|
|
2
|
+
import click
|
|
3
|
+
from circle_so.config import Config
|
|
4
|
+
|
|
5
|
+
pass_config = click.make_pass_decorator(Config, ensure=True)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.option("--db", envvar="CIRCLE_SO_DB", default="./circle-so.db", help="Path to SQLite database")
|
|
10
|
+
@click.option("--rate-limit", envvar="CIRCLE_SO_RATE_LIMIT", default=5, type=int, help="API requests per second")
|
|
11
|
+
@click.pass_context
|
|
12
|
+
def main(ctx, db, rate_limit):
|
|
13
|
+
"""CLI toolkit for managing Circle.so communities at scale."""
|
|
14
|
+
ctx.ensure_object(Config)
|
|
15
|
+
ctx.obj = Config(db_path=db, rate_limit=rate_limit)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@main.group()
|
|
19
|
+
@pass_config
|
|
20
|
+
def spaces(config):
|
|
21
|
+
"""Manage spaces."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.group()
|
|
25
|
+
@pass_config
|
|
26
|
+
def members(config):
|
|
27
|
+
"""Manage members."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@main.group()
|
|
31
|
+
@pass_config
|
|
32
|
+
def moderators(config):
|
|
33
|
+
"""Manage moderators."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.group()
|
|
37
|
+
@pass_config
|
|
38
|
+
def report(config):
|
|
39
|
+
"""Generate reports."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Register subcommands
|
|
43
|
+
from circle_so.commands.spaces import register as register_spaces
|
|
44
|
+
from circle_so.commands.members import register as register_members
|
|
45
|
+
from circle_so.commands.moderators import register as register_moderators
|
|
46
|
+
from circle_so.commands.report import register as register_report
|
|
47
|
+
|
|
48
|
+
register_spaces(spaces)
|
|
49
|
+
register_members(members)
|
|
50
|
+
register_moderators(moderators)
|
|
51
|
+
register_report(report)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands package."""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Member management commands."""
|
|
2
|
+
import csv
|
|
3
|
+
import click
|
|
4
|
+
from circle_so.config import Config
|
|
5
|
+
|
|
6
|
+
pass_config = click.make_pass_decorator(Config)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(group):
|
|
10
|
+
group.add_command(import_members)
|
|
11
|
+
group.add_command(audit)
|
|
12
|
+
group.add_command(add)
|
|
13
|
+
group.add_command(fix_missing)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command("import")
|
|
17
|
+
@click.argument("csv_path", type=click.Path(exists=True))
|
|
18
|
+
@pass_config
|
|
19
|
+
def import_members(config, csv_path):
|
|
20
|
+
"""Import members from CSV into local database."""
|
|
21
|
+
conn = config.get_db()
|
|
22
|
+
count = 0
|
|
23
|
+
with open(csv_path, newline="") as f:
|
|
24
|
+
reader = csv.DictReader(f)
|
|
25
|
+
plg_col = next((c for c in reader.fieldnames if c.lower() in ("plgs", "plg")), None)
|
|
26
|
+
country_col = next((c for c in reader.fieldnames if c.lower() == "country"), None)
|
|
27
|
+
for row in reader:
|
|
28
|
+
conn.execute(
|
|
29
|
+
"INSERT OR IGNORE INTO members (first_name, email, country, plg) VALUES (?, ?, ?, ?)",
|
|
30
|
+
(row.get("first_name", "").strip(), row.get("email", "").strip(),
|
|
31
|
+
row.get(country_col, "").strip() if country_col else "",
|
|
32
|
+
row.get(plg_col, "").strip() if plg_col else ""))
|
|
33
|
+
count += 1
|
|
34
|
+
conn.commit()
|
|
35
|
+
total = conn.execute("SELECT COUNT(*) FROM members").fetchone()[0]
|
|
36
|
+
click.echo(f"Imported {count} rows. Total: {total}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.command()
|
|
40
|
+
@click.option("--prefix", default="all", help="Filter: kcna, ckad, or all")
|
|
41
|
+
@click.option("--cache", is_flag=True, help="Use cached data")
|
|
42
|
+
@pass_config
|
|
43
|
+
def audit(config, prefix, cache):
|
|
44
|
+
"""Audit members against Circle API."""
|
|
45
|
+
from circle.pagination import paginate
|
|
46
|
+
conn = config.get_db()
|
|
47
|
+
client = config.get_client()
|
|
48
|
+
where = f"AND plg LIKE '{prefix.upper()}%'" if prefix != "all" else ""
|
|
49
|
+
spaces = conn.execute(f"SELECT plg, space_id FROM spaces WHERE space_id IS NOT NULL {where} ORDER BY plg").fetchall()
|
|
50
|
+
click.echo(f"Auditing {len(spaces)} spaces...")
|
|
51
|
+
conn.execute(f"UPDATE members SET in_space = 0 WHERE 1=1 {where}")
|
|
52
|
+
for plg, space_id in spaces:
|
|
53
|
+
emails = set()
|
|
54
|
+
for m in paginate(client.admin.spaces.list_space_members, space_id=space_id, per_page=200):
|
|
55
|
+
cm = m.community_member
|
|
56
|
+
if cm and cm.email:
|
|
57
|
+
emails.add(cm.email.lower())
|
|
58
|
+
expected = conn.execute("SELECT id, email FROM members WHERE plg = ?", (plg,)).fetchall()
|
|
59
|
+
matched = sum(1 for lid, email in expected if email.strip().lower() in emails)
|
|
60
|
+
for lid, email in expected:
|
|
61
|
+
if email.strip().lower() in emails:
|
|
62
|
+
conn.execute("UPDATE members SET in_space = 1 WHERE id = ?", (lid,))
|
|
63
|
+
click.echo(f"{plg}: {matched}/{len(expected)} matched, {len(expected)-matched} missing")
|
|
64
|
+
conn.commit()
|
|
65
|
+
client.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.command()
|
|
69
|
+
@click.argument("csv_path", type=click.Path(exists=True))
|
|
70
|
+
@click.option("--space", required=True, help="PLG name, e.g. 'KCNA 048'")
|
|
71
|
+
@click.option("--dry-run", is_flag=True)
|
|
72
|
+
@pass_config
|
|
73
|
+
def add(config, csv_path, space, dry_run):
|
|
74
|
+
"""Add members from CSV to a space."""
|
|
75
|
+
from circle.exceptions import CircleAPIError, NotFoundError
|
|
76
|
+
conn = config.get_db()
|
|
77
|
+
row = conn.execute("SELECT space_id FROM spaces WHERE plg=?", (space,)).fetchone()
|
|
78
|
+
if not row:
|
|
79
|
+
click.echo(f"PLG '{space}' not found. Run: circle-so spaces list")
|
|
80
|
+
return
|
|
81
|
+
space_id = row[0]
|
|
82
|
+
users = []
|
|
83
|
+
with open(csv_path, newline="") as f:
|
|
84
|
+
for r in csv.reader(f):
|
|
85
|
+
if len(r) >= 2 and r[1].strip():
|
|
86
|
+
users.append({"name": r[0].strip(), "email": r[1].strip()})
|
|
87
|
+
click.echo(f"{len(users)} users -> {space} (id={space_id})")
|
|
88
|
+
if dry_run:
|
|
89
|
+
for u in users:
|
|
90
|
+
click.echo(f"[DRY RUN] {u['name']} ({u['email']})")
|
|
91
|
+
return
|
|
92
|
+
client = config.get_client()
|
|
93
|
+
sg_id = int(os.environ.get("CIRCLE_SPACE_GROUP_ID", "0"))
|
|
94
|
+
import os
|
|
95
|
+
success = failed = 0
|
|
96
|
+
for u in users:
|
|
97
|
+
try:
|
|
98
|
+
client.admin.spaces.add_space_member(email=u["email"], space_id=space_id)
|
|
99
|
+
success += 1
|
|
100
|
+
except NotFoundError:
|
|
101
|
+
try:
|
|
102
|
+
client.admin.community.create_community_member(
|
|
103
|
+
email=u["email"], name=u["name"], skip_invitation=True,
|
|
104
|
+
space_group_ids=[sg_id] if sg_id else [])
|
|
105
|
+
client.admin.spaces.add_space_member(email=u["email"], space_id=space_id)
|
|
106
|
+
success += 1
|
|
107
|
+
except CircleAPIError as e:
|
|
108
|
+
click.echo(f"FAILED: {u['email']}: {e}")
|
|
109
|
+
failed += 1
|
|
110
|
+
except CircleAPIError:
|
|
111
|
+
success += 1 # likely already member
|
|
112
|
+
click.echo(f"Done: {success} added, {failed} failed")
|
|
113
|
+
client.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@click.command("fix-missing")
|
|
117
|
+
@click.option("--dry-run", is_flag=True)
|
|
118
|
+
@pass_config
|
|
119
|
+
def fix_missing(config, dry_run):
|
|
120
|
+
"""Add all members marked as missing in the database to their spaces."""
|
|
121
|
+
import os
|
|
122
|
+
from circle.exceptions import CircleAPIError, NotFoundError
|
|
123
|
+
conn = config.get_db()
|
|
124
|
+
missing = conn.execute("""
|
|
125
|
+
SELECT id, first_name, email, plg, space_id FROM members
|
|
126
|
+
WHERE in_space = 0 AND space_id IS NOT NULL ORDER BY plg
|
|
127
|
+
""").fetchall()
|
|
128
|
+
click.echo(f"{len(missing)} missing members")
|
|
129
|
+
if dry_run:
|
|
130
|
+
for _, name, email, plg, _ in missing:
|
|
131
|
+
click.echo(f"[DRY RUN] {name} ({email}) -> {plg}")
|
|
132
|
+
return
|
|
133
|
+
client = config.get_client()
|
|
134
|
+
sg_id = int(os.environ.get("CIRCLE_SPACE_GROUP_ID", "0"))
|
|
135
|
+
added = failed = 0
|
|
136
|
+
for lid, name, email, plg, space_id in missing:
|
|
137
|
+
try:
|
|
138
|
+
client.admin.spaces.add_space_member(email=email, space_id=space_id)
|
|
139
|
+
except NotFoundError:
|
|
140
|
+
try:
|
|
141
|
+
client.admin.community.create_community_member(
|
|
142
|
+
email=email, name=name, skip_invitation=True,
|
|
143
|
+
space_group_ids=[sg_id] if sg_id else [])
|
|
144
|
+
client.admin.spaces.add_space_member(email=email, space_id=space_id)
|
|
145
|
+
except CircleAPIError as e:
|
|
146
|
+
click.echo(f"FAILED: {email}: {e}")
|
|
147
|
+
failed += 1
|
|
148
|
+
continue
|
|
149
|
+
except CircleAPIError:
|
|
150
|
+
pass
|
|
151
|
+
conn.execute("UPDATE members SET in_space = 1 WHERE id = ?", (lid,))
|
|
152
|
+
added += 1
|
|
153
|
+
conn.commit()
|
|
154
|
+
click.echo(f"Done: {added} added, {failed} failed")
|
|
155
|
+
client.close()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Moderator management commands."""
|
|
2
|
+
import csv
|
|
3
|
+
import click
|
|
4
|
+
from circle_so.config import Config
|
|
5
|
+
|
|
6
|
+
pass_config = click.make_pass_decorator(Config)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(group):
|
|
10
|
+
group.add_command(verify)
|
|
11
|
+
group.add_command(add)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.argument("csv_path", type=click.Path(exists=True))
|
|
16
|
+
@pass_config
|
|
17
|
+
def verify(config, csv_path):
|
|
18
|
+
"""Verify moderators are in their spaces with moderator status."""
|
|
19
|
+
from circle.exceptions import CircleAPIError
|
|
20
|
+
conn = config.get_db()
|
|
21
|
+
client = config.get_client()
|
|
22
|
+
mods = _parse_moderators_csv(csv_path)
|
|
23
|
+
click.echo(f"{'Name':<35} {'PLG':<12} {'In Space':<10} {'Moderator'}")
|
|
24
|
+
click.echo("-" * 72)
|
|
25
|
+
in_space = not_in = moderators = 0
|
|
26
|
+
for m in mods:
|
|
27
|
+
space = conn.execute("SELECT space_id FROM spaces WHERE plg=?", (m["plg"],)).fetchone()
|
|
28
|
+
if not space:
|
|
29
|
+
click.echo(f"{m['name']:<35} {m['plg']:<12} {'NO SPACE':<10} -")
|
|
30
|
+
continue
|
|
31
|
+
try:
|
|
32
|
+
sm = client.admin.spaces.show_space_member(email=m["email"], space_id=space[0])
|
|
33
|
+
is_mod = sm.moderator
|
|
34
|
+
click.echo(f"{m['name']:<35} {m['plg']:<12} {'YES':<10} {'YES' if is_mod else 'NO'}")
|
|
35
|
+
in_space += 1
|
|
36
|
+
if is_mod:
|
|
37
|
+
moderators += 1
|
|
38
|
+
except CircleAPIError:
|
|
39
|
+
click.echo(f"{m['name']:<35} {m['plg']:<12} {'NO':<10} -")
|
|
40
|
+
not_in += 1
|
|
41
|
+
click.echo(f"\n{in_space} in space, {not_in} missing, {moderators}/{in_space} moderators")
|
|
42
|
+
client.close()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command()
|
|
46
|
+
@click.argument("csv_path", type=click.Path(exists=True))
|
|
47
|
+
@click.option("--dry-run", is_flag=True)
|
|
48
|
+
@pass_config
|
|
49
|
+
def add(config, csv_path, dry_run):
|
|
50
|
+
"""Add moderators to their spaces (membership only -- moderator role requires Circle UI)."""
|
|
51
|
+
import os
|
|
52
|
+
from circle.exceptions import CircleAPIError, NotFoundError
|
|
53
|
+
conn = config.get_db()
|
|
54
|
+
client = config.get_client()
|
|
55
|
+
sg_id = int(os.environ.get("CIRCLE_SPACE_GROUP_ID", "0"))
|
|
56
|
+
mods = _parse_moderators_csv(csv_path)
|
|
57
|
+
click.echo(f"{len(mods)} moderators to process")
|
|
58
|
+
added = already = failed = 0
|
|
59
|
+
for m in mods:
|
|
60
|
+
space = conn.execute("SELECT space_id FROM spaces WHERE plg=?", (m["plg"],)).fetchone()
|
|
61
|
+
if not space:
|
|
62
|
+
click.echo(f"SKIP: {m['name']} -> {m['plg']} (no space)")
|
|
63
|
+
continue
|
|
64
|
+
if dry_run:
|
|
65
|
+
click.echo(f"[DRY RUN] {m['name']} -> {m['plg']}")
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
client.admin.spaces.show_space_member(email=m["email"], space_id=space[0])
|
|
69
|
+
already += 1
|
|
70
|
+
continue
|
|
71
|
+
except CircleAPIError:
|
|
72
|
+
pass
|
|
73
|
+
try:
|
|
74
|
+
client.admin.spaces.add_space_member(email=m["email"], space_id=space[0])
|
|
75
|
+
click.echo(f"Added: {m['name']} -> {m['plg']}")
|
|
76
|
+
added += 1
|
|
77
|
+
except NotFoundError:
|
|
78
|
+
try:
|
|
79
|
+
client.admin.community.create_community_member(
|
|
80
|
+
email=m["email"], name=m["name"], skip_invitation=True,
|
|
81
|
+
space_group_ids=[sg_id] if sg_id else [])
|
|
82
|
+
client.admin.spaces.add_space_member(email=m["email"], space_id=space[0])
|
|
83
|
+
click.echo(f"Invited+Added: {m['name']} -> {m['plg']}")
|
|
84
|
+
added += 1
|
|
85
|
+
except CircleAPIError as e:
|
|
86
|
+
click.echo(f"FAILED: {m['name']}: {e}")
|
|
87
|
+
failed += 1
|
|
88
|
+
except CircleAPIError:
|
|
89
|
+
already += 1
|
|
90
|
+
click.echo(f"\nDone: {added} added, {already} already, {failed} failed")
|
|
91
|
+
click.echo("NOTE: Moderator role must be assigned in Circle UI.")
|
|
92
|
+
client.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_moderators_csv(csv_path):
|
|
96
|
+
"""Auto-detect CSV format for moderators."""
|
|
97
|
+
mods = []
|
|
98
|
+
with open(csv_path, newline="") as f:
|
|
99
|
+
reader = csv.reader(f)
|
|
100
|
+
first = next(reader)
|
|
101
|
+
f.seek(0)
|
|
102
|
+
if any("email" in c.lower() for c in first):
|
|
103
|
+
for row in csv.DictReader(f):
|
|
104
|
+
email = (row.get("email") or row.get("Email Address") or row.get("Email") or "").strip()
|
|
105
|
+
name = f"{row.get('first_name', row.get('First Name', '')).strip()} {row.get('last_name', row.get('Last Name', '')).strip()}".strip()
|
|
106
|
+
plg = (row.get("PLGs") or row.get("PLG") or "").strip()
|
|
107
|
+
if email:
|
|
108
|
+
mods.append({"email": email, "name": name, "plg": plg})
|
|
109
|
+
else:
|
|
110
|
+
f.seek(0)
|
|
111
|
+
for row in csv.reader(f):
|
|
112
|
+
if len(row) >= 3:
|
|
113
|
+
mods.append({"email": row[0].strip(), "name": f"{row[1].strip()} {row[2].strip()}", "plg": row[-1].strip()})
|
|
114
|
+
return mods
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Report commands."""
|
|
2
|
+
import csv
|
|
3
|
+
import os
|
|
4
|
+
import click
|
|
5
|
+
from circle_so.config import Config
|
|
6
|
+
|
|
7
|
+
pass_config = click.make_pass_decorator(Config)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(group):
|
|
11
|
+
group.add_command(counts)
|
|
12
|
+
group.add_command(inactive)
|
|
13
|
+
group.add_command(missing)
|
|
14
|
+
group.add_command(export)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.option("--prefix", default="all", help="Filter: kcna, ckad, or all")
|
|
19
|
+
@click.option("--over", default=0, type=int, help="Only show PLGs with more than N members")
|
|
20
|
+
@pass_config
|
|
21
|
+
def counts(config, prefix, over):
|
|
22
|
+
"""Show member counts per PLG."""
|
|
23
|
+
conn = config.get_db()
|
|
24
|
+
where = f"WHERE m.plg LIKE '{prefix.upper()}%'" if prefix != "all" else ""
|
|
25
|
+
rows = conn.execute(f"""
|
|
26
|
+
SELECT m.plg, COUNT(*) as cnt, s.space_id, s.slug
|
|
27
|
+
FROM members m LEFT JOIN spaces s ON m.plg = s.plg
|
|
28
|
+
{where} GROUP BY m.plg ORDER BY m.plg
|
|
29
|
+
""").fetchall()
|
|
30
|
+
click.echo(f"{'PLG':<12} {'Count':<8} {'Space ID':<10} {'Slug'}")
|
|
31
|
+
click.echo("-" * 45)
|
|
32
|
+
total = 0
|
|
33
|
+
for plg, cnt, sid, slug in rows:
|
|
34
|
+
if cnt > over:
|
|
35
|
+
click.echo(f"{plg:<12} {cnt:<8} {sid or 'MISSING':<10} {slug or 'MISSING'}")
|
|
36
|
+
total += cnt
|
|
37
|
+
click.echo(f"\nTotal: {total}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.command()
|
|
41
|
+
@pass_config
|
|
42
|
+
def inactive(config):
|
|
43
|
+
"""List inactive members from last audit."""
|
|
44
|
+
conn = config.get_db()
|
|
45
|
+
try:
|
|
46
|
+
rows = conn.execute("""
|
|
47
|
+
SELECT space_name, COUNT(*) FROM move_audit WHERE status='inactive'
|
|
48
|
+
GROUP BY space_name ORDER BY space_name
|
|
49
|
+
""").fetchall()
|
|
50
|
+
for name, cnt in rows:
|
|
51
|
+
click.echo(f"{name}: {cnt} inactive")
|
|
52
|
+
except Exception:
|
|
53
|
+
click.echo("No audit data. Run: circle-so members audit")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.command()
|
|
57
|
+
@pass_config
|
|
58
|
+
def missing(config):
|
|
59
|
+
"""List members not in their assigned space."""
|
|
60
|
+
conn = config.get_db()
|
|
61
|
+
rows = conn.execute("""
|
|
62
|
+
SELECT plg, COUNT(*) FROM members
|
|
63
|
+
WHERE in_space = 0 AND space_id IS NOT NULL
|
|
64
|
+
GROUP BY plg ORDER BY plg
|
|
65
|
+
""").fetchall()
|
|
66
|
+
if not rows:
|
|
67
|
+
click.echo("No missing members.")
|
|
68
|
+
return
|
|
69
|
+
for plg, cnt in rows:
|
|
70
|
+
click.echo(f"{plg}: {cnt} missing")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@click.command()
|
|
74
|
+
@click.argument("report_type", type=click.Choice(["inactive", "missing", "moves", "plg_changes"]))
|
|
75
|
+
@click.option("--output", "-o", default=None, help="Output path (default: data dir)")
|
|
76
|
+
@pass_config
|
|
77
|
+
def export(config, report_type, output):
|
|
78
|
+
"""Export report to CSV."""
|
|
79
|
+
conn = config.get_db()
|
|
80
|
+
queries = {
|
|
81
|
+
"inactive": "SELECT space_name, email, access_type, created_at FROM move_audit WHERE status='inactive' ORDER BY space_name",
|
|
82
|
+
"missing": "SELECT plg, first_name, email FROM members WHERE in_space=0 AND space_id IS NOT NULL ORDER BY plg",
|
|
83
|
+
"moves": "SELECT from_plg, to_plg, COUNT(*) FROM moves WHERE status='done' GROUP BY from_plg, to_plg",
|
|
84
|
+
"plg_changes": "SELECT name, email, from_plg, to_plg FROM moves WHERE status='done' ORDER BY to_plg",
|
|
85
|
+
}
|
|
86
|
+
query = queries[report_type]
|
|
87
|
+
rows = conn.execute(query).fetchall()
|
|
88
|
+
cols = [d[0] for d in conn.execute(query).description]
|
|
89
|
+
out_path = output or os.path.join(config.data_dir, f"{report_type}.csv")
|
|
90
|
+
with open(out_path, "w", newline="") as f:
|
|
91
|
+
w = csv.writer(f)
|
|
92
|
+
w.writerow(cols)
|
|
93
|
+
w.writerows(rows)
|
|
94
|
+
click.echo(f"Exported {len(rows)} rows to {out_path}")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Space management commands."""
|
|
2
|
+
import click
|
|
3
|
+
from circle_so.config import Config
|
|
4
|
+
|
|
5
|
+
pass_config = click.make_pass_decorator(Config)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register(group):
|
|
9
|
+
group.add_command(list_spaces)
|
|
10
|
+
group.add_command(search)
|
|
11
|
+
group.add_command(rename)
|
|
12
|
+
group.add_command(lock)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("list")
|
|
16
|
+
@click.option("--prefix", default="all", help="Filter by prefix: kcna, ckad, or all")
|
|
17
|
+
@click.option("--cache", is_flag=True, help="Use cached data instead of API")
|
|
18
|
+
@pass_config
|
|
19
|
+
def list_spaces(config, prefix, cache):
|
|
20
|
+
"""List all spaces."""
|
|
21
|
+
from circle.pagination import paginate
|
|
22
|
+
client = config.get_client()
|
|
23
|
+
for s in paginate(client.admin.spaces.list_spaces, per_page=200):
|
|
24
|
+
slug = s.slug or ""
|
|
25
|
+
if prefix == "all" or slug.startswith(prefix.lower()):
|
|
26
|
+
click.echo(f"{s.name:<16} id={s.id:<10} slug={slug}")
|
|
27
|
+
client.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.command()
|
|
31
|
+
@click.argument("names", nargs=-1, required=True)
|
|
32
|
+
@pass_config
|
|
33
|
+
def search(config, names):
|
|
34
|
+
"""Search for spaces by name or slug."""
|
|
35
|
+
from circle.pagination import paginate
|
|
36
|
+
client = config.get_client()
|
|
37
|
+
all_spaces = list(paginate(client.admin.spaces.list_spaces, per_page=200))
|
|
38
|
+
for target in names:
|
|
39
|
+
target_slug = target.lower().replace(" ", "-")
|
|
40
|
+
click.echo(f"'{target}':")
|
|
41
|
+
found = False
|
|
42
|
+
for s in all_spaces:
|
|
43
|
+
if (s.name and s.name.strip() == target) or (s.slug and s.slug.startswith(target_slug)):
|
|
44
|
+
click.echo(f" name='{s.name}' id={s.id} slug={s.slug} type={s.space_type}")
|
|
45
|
+
found = True
|
|
46
|
+
if not found:
|
|
47
|
+
click.echo(" Not found")
|
|
48
|
+
client.close()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.command()
|
|
52
|
+
@click.argument("space_id", type=int)
|
|
53
|
+
@click.option("--name", required=True, help="New space name")
|
|
54
|
+
@click.option("--slug", default=None, help="New slug (auto-generated if omitted)")
|
|
55
|
+
@pass_config
|
|
56
|
+
def rename(config, space_id, name, slug):
|
|
57
|
+
"""Rename a space."""
|
|
58
|
+
client = config.get_client()
|
|
59
|
+
kwargs = {"name": name}
|
|
60
|
+
if slug:
|
|
61
|
+
kwargs["slug"] = slug
|
|
62
|
+
result = client.admin.spaces.update_space(space_id, **kwargs)
|
|
63
|
+
click.echo(f"Renamed: {result.name} (slug={result.slug})")
|
|
64
|
+
client.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@click.command()
|
|
68
|
+
@click.option("--prefix", default="all", help="Filter: kcna, ckad, or all")
|
|
69
|
+
@click.option("--unlock", is_flag=True, help="Unlock instead of lock")
|
|
70
|
+
@click.option("--dry-run", is_flag=True, help="Preview without changes")
|
|
71
|
+
@pass_config
|
|
72
|
+
def lock(config, prefix, unlock, dry_run):
|
|
73
|
+
"""Lock/unlock spaces: disable member posting and member-adding."""
|
|
74
|
+
from circle.pagination import paginate
|
|
75
|
+
client = config.get_client()
|
|
76
|
+
action = "Unlocking" if unlock else "Locking"
|
|
77
|
+
success = 0
|
|
78
|
+
for s in paginate(client.admin.spaces.list_spaces, per_page=200):
|
|
79
|
+
slug = s.slug or ""
|
|
80
|
+
if prefix != "all" and not slug.startswith(prefix.lower()):
|
|
81
|
+
continue
|
|
82
|
+
if dry_run:
|
|
83
|
+
click.echo(f"[DRY RUN] {action}: {s.name}")
|
|
84
|
+
continue
|
|
85
|
+
client.admin.spaces.update_space(s.id,
|
|
86
|
+
is_post_disabled=not unlock,
|
|
87
|
+
prevent_members_from_adding_others=not unlock)
|
|
88
|
+
click.echo(f"{action}: {s.name}")
|
|
89
|
+
success += 1
|
|
90
|
+
click.echo(f"\nDone: {success} spaces")
|
|
91
|
+
client.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Configuration management. Reads from env vars, .env file, and CLI flags."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
load_dotenv()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
def __init__(self, **overrides):
|
|
12
|
+
self.api_token = overrides.get("api_token") or os.environ.get("CIRCLE_API_TOKEN")
|
|
13
|
+
self.community_url = overrides.get("community_url") or os.environ.get("CIRCLE_COMMUNITY_URL")
|
|
14
|
+
self.db_path = overrides.get("db_path") or os.environ.get("CIRCLE_SO_DB", "./circle-so.db")
|
|
15
|
+
self.data_dir = overrides.get("data_dir") or os.environ.get("CIRCLE_SO_DATA_DIR", ".")
|
|
16
|
+
self.rate_limit = int(overrides.get("rate_limit") or os.environ.get("CIRCLE_SO_RATE_LIMIT", "5"))
|
|
17
|
+
self.cache_ttl = int(overrides.get("cache_ttl") or os.environ.get("CIRCLE_SO_CACHE_TTL", "3600"))
|
|
18
|
+
|
|
19
|
+
def get_client(self):
|
|
20
|
+
from circle import CircleClient
|
|
21
|
+
return CircleClient(
|
|
22
|
+
api_token=self.api_token,
|
|
23
|
+
community_url=self.community_url,
|
|
24
|
+
rate_limit=self.rate_limit,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def get_db(self):
|
|
28
|
+
from circle_so.db.connection import get_connection
|
|
29
|
+
return get_connection(self.db_path)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DB package."""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""DB connection and schema migrations."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
MIGRATIONS = [
|
|
5
|
+
# v1: core tables
|
|
6
|
+
"""
|
|
7
|
+
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
|
8
|
+
INSERT OR IGNORE INTO schema_version VALUES (0);
|
|
9
|
+
""",
|
|
10
|
+
# v1: spaces
|
|
11
|
+
"""
|
|
12
|
+
CREATE TABLE IF NOT EXISTS spaces (
|
|
13
|
+
id INTEGER PRIMARY KEY,
|
|
14
|
+
plg TEXT UNIQUE,
|
|
15
|
+
space_id INTEGER,
|
|
16
|
+
slug TEXT
|
|
17
|
+
);
|
|
18
|
+
""",
|
|
19
|
+
# v2: members
|
|
20
|
+
"""
|
|
21
|
+
CREATE TABLE IF NOT EXISTS members (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
first_name TEXT,
|
|
24
|
+
email TEXT UNIQUE,
|
|
25
|
+
country TEXT,
|
|
26
|
+
plg TEXT,
|
|
27
|
+
space_id INTEGER,
|
|
28
|
+
in_community BOOLEAN,
|
|
29
|
+
in_space BOOLEAN,
|
|
30
|
+
community_member_id INTEGER
|
|
31
|
+
);
|
|
32
|
+
""",
|
|
33
|
+
# v3: moderators
|
|
34
|
+
"""
|
|
35
|
+
CREATE TABLE IF NOT EXISTS moderators (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
email TEXT,
|
|
38
|
+
name TEXT,
|
|
39
|
+
plg TEXT,
|
|
40
|
+
space_id INTEGER,
|
|
41
|
+
in_space BOOLEAN,
|
|
42
|
+
is_moderator BOOLEAN,
|
|
43
|
+
UNIQUE(email, plg)
|
|
44
|
+
);
|
|
45
|
+
""",
|
|
46
|
+
# v4: moves
|
|
47
|
+
"""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS moves (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
email TEXT,
|
|
51
|
+
name TEXT,
|
|
52
|
+
community_member_id INTEGER,
|
|
53
|
+
from_space_id INTEGER,
|
|
54
|
+
from_plg TEXT,
|
|
55
|
+
to_space_id INTEGER,
|
|
56
|
+
to_plg TEXT,
|
|
57
|
+
status TEXT DEFAULT 'planned'
|
|
58
|
+
);
|
|
59
|
+
""",
|
|
60
|
+
# v5: cache
|
|
61
|
+
"""
|
|
62
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
63
|
+
key TEXT PRIMARY KEY,
|
|
64
|
+
data TEXT,
|
|
65
|
+
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
66
|
+
);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS space_members_cache (
|
|
68
|
+
id INTEGER PRIMARY KEY,
|
|
69
|
+
space_id INTEGER,
|
|
70
|
+
email TEXT,
|
|
71
|
+
community_member_id INTEGER,
|
|
72
|
+
moderator BOOLEAN,
|
|
73
|
+
access_type TEXT,
|
|
74
|
+
status TEXT,
|
|
75
|
+
created_at TEXT,
|
|
76
|
+
UNIQUE(space_id, email)
|
|
77
|
+
);
|
|
78
|
+
""",
|
|
79
|
+
# v6: engaged users
|
|
80
|
+
"""
|
|
81
|
+
CREATE TABLE IF NOT EXISTS engaged_users (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
space_id INTEGER,
|
|
84
|
+
community_member_id INTEGER,
|
|
85
|
+
engagement_type TEXT,
|
|
86
|
+
UNIQUE(space_id, community_member_id, engagement_type)
|
|
87
|
+
);
|
|
88
|
+
""",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_connection(db_path: str) -> sqlite3.Connection:
|
|
93
|
+
conn = sqlite3.connect(db_path)
|
|
94
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
95
|
+
_migrate(conn)
|
|
96
|
+
return conn
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _migrate(conn: sqlite3.Connection):
|
|
100
|
+
conn.execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)")
|
|
101
|
+
conn.execute("INSERT OR IGNORE INTO schema_version VALUES (0)")
|
|
102
|
+
current = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
103
|
+
|
|
104
|
+
for i, sql in enumerate(MIGRATIONS):
|
|
105
|
+
if i <= current:
|
|
106
|
+
continue
|
|
107
|
+
conn.executescript(sql)
|
|
108
|
+
conn.execute("INSERT INTO schema_version VALUES (?)", (i,))
|
|
109
|
+
conn.commit()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Test fixtures."""
|
|
2
|
+
import os
|
|
3
|
+
import pytest
|
|
4
|
+
import sqlite3
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
from circle_so.cli import main
|
|
7
|
+
from circle_so.config import Config
|
|
8
|
+
from circle_so.db.connection import get_connection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def runner():
|
|
13
|
+
return CliRunner()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def tmp_db(tmp_path):
|
|
18
|
+
db_path = str(tmp_path / "test.db")
|
|
19
|
+
conn = get_connection(db_path)
|
|
20
|
+
yield db_path, conn
|
|
21
|
+
conn.close()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def env_setup(tmp_path, monkeypatch):
|
|
26
|
+
monkeypatch.setenv("CIRCLE_API_TOKEN", "test_token")
|
|
27
|
+
monkeypatch.setenv("CIRCLE_SO_DB", str(tmp_path / "test.db"))
|
|
28
|
+
monkeypatch.setenv("CIRCLE_SO_DATA_DIR", str(tmp_path))
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Tests for CLI commands."""
|
|
2
|
+
import os
|
|
3
|
+
import csv
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from circle_so.cli import main
|
|
6
|
+
from circle_so.db.connection import get_connection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestCLI:
|
|
10
|
+
def test_help(self):
|
|
11
|
+
result = CliRunner().invoke(main, ["--help"])
|
|
12
|
+
assert result.exit_code == 0
|
|
13
|
+
assert "spaces" in result.output
|
|
14
|
+
assert "members" in result.output
|
|
15
|
+
assert "moderators" in result.output
|
|
16
|
+
assert "report" in result.output
|
|
17
|
+
|
|
18
|
+
def test_spaces_help(self):
|
|
19
|
+
result = CliRunner().invoke(main, ["spaces", "--help"])
|
|
20
|
+
assert result.exit_code == 0
|
|
21
|
+
assert "list" in result.output
|
|
22
|
+
assert "search" in result.output
|
|
23
|
+
assert "rename" in result.output
|
|
24
|
+
assert "lock" in result.output
|
|
25
|
+
|
|
26
|
+
def test_members_help(self):
|
|
27
|
+
result = CliRunner().invoke(main, ["members", "--help"])
|
|
28
|
+
assert result.exit_code == 0
|
|
29
|
+
assert "import" in result.output
|
|
30
|
+
assert "audit" in result.output
|
|
31
|
+
assert "add" in result.output
|
|
32
|
+
assert "fix-missing" in result.output
|
|
33
|
+
|
|
34
|
+
def test_moderators_help(self):
|
|
35
|
+
result = CliRunner().invoke(main, ["moderators", "--help"])
|
|
36
|
+
assert result.exit_code == 0
|
|
37
|
+
assert "verify" in result.output
|
|
38
|
+
assert "add" in result.output
|
|
39
|
+
|
|
40
|
+
def test_report_help(self):
|
|
41
|
+
result = CliRunner().invoke(main, ["report", "--help"])
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert "counts" in result.output
|
|
44
|
+
assert "inactive" in result.output
|
|
45
|
+
assert "missing" in result.output
|
|
46
|
+
assert "export" in result.output
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestMembersImport:
|
|
50
|
+
def test_import_csv(self, tmp_path, monkeypatch):
|
|
51
|
+
db_path = str(tmp_path / "test.db")
|
|
52
|
+
monkeypatch.setenv("CIRCLE_SO_DB", db_path)
|
|
53
|
+
|
|
54
|
+
csv_path = str(tmp_path / "learners.csv")
|
|
55
|
+
with open(csv_path, "w", newline="") as f:
|
|
56
|
+
w = csv.writer(f)
|
|
57
|
+
w.writerow(["first_name", "email", "country", "PLG"])
|
|
58
|
+
w.writerow(["Alice", "alice@example.com", "Kenya", "KCNA 001"])
|
|
59
|
+
w.writerow(["Bob", "bob@example.com", "Nigeria", "KCNA 002"])
|
|
60
|
+
|
|
61
|
+
result = CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert "Imported 2" in result.output
|
|
64
|
+
|
|
65
|
+
conn = get_connection(db_path)
|
|
66
|
+
count = conn.execute("SELECT COUNT(*) FROM members").fetchone()[0]
|
|
67
|
+
assert count == 2
|
|
68
|
+
conn.close()
|
|
69
|
+
|
|
70
|
+
def test_import_idempotent(self, tmp_path, monkeypatch):
|
|
71
|
+
db_path = str(tmp_path / "test.db")
|
|
72
|
+
monkeypatch.setenv("CIRCLE_SO_DB", db_path)
|
|
73
|
+
|
|
74
|
+
csv_path = str(tmp_path / "learners.csv")
|
|
75
|
+
with open(csv_path, "w", newline="") as f:
|
|
76
|
+
w = csv.writer(f)
|
|
77
|
+
w.writerow(["first_name", "email", "country", "PLG"])
|
|
78
|
+
w.writerow(["Alice", "alice@example.com", "Kenya", "KCNA 001"])
|
|
79
|
+
|
|
80
|
+
CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
|
|
81
|
+
CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
|
|
82
|
+
|
|
83
|
+
conn = get_connection(db_path)
|
|
84
|
+
count = conn.execute("SELECT COUNT(*) FROM members").fetchone()[0]
|
|
85
|
+
assert count == 1
|
|
86
|
+
conn.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestReportCounts:
|
|
90
|
+
def test_counts_empty(self, tmp_path, monkeypatch):
|
|
91
|
+
db_path = str(tmp_path / "test.db")
|
|
92
|
+
monkeypatch.setenv("CIRCLE_SO_DB", db_path)
|
|
93
|
+
result = CliRunner().invoke(main, ["--db", db_path, "report", "counts"])
|
|
94
|
+
assert result.exit_code == 0
|
|
95
|
+
assert "Total: 0" in result.output
|
|
96
|
+
|
|
97
|
+
def test_counts_with_data(self, tmp_path, monkeypatch):
|
|
98
|
+
db_path = str(tmp_path / "test.db")
|
|
99
|
+
monkeypatch.setenv("CIRCLE_SO_DB", db_path)
|
|
100
|
+
|
|
101
|
+
csv_path = str(tmp_path / "learners.csv")
|
|
102
|
+
with open(csv_path, "w", newline="") as f:
|
|
103
|
+
w = csv.writer(f)
|
|
104
|
+
w.writerow(["first_name", "email", "country", "PLG"])
|
|
105
|
+
w.writerow(["Alice", "a@example.com", "Kenya", "KCNA 001"])
|
|
106
|
+
w.writerow(["Bob", "b@example.com", "Kenya", "KCNA 001"])
|
|
107
|
+
w.writerow(["Carol", "c@example.com", "Kenya", "KCNA 002"])
|
|
108
|
+
|
|
109
|
+
CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
|
|
110
|
+
result = CliRunner().invoke(main, ["--db", db_path, "report", "counts"])
|
|
111
|
+
assert result.exit_code == 0
|
|
112
|
+
assert "KCNA 001" in result.output
|
|
113
|
+
assert "KCNA 002" in result.output
|
|
114
|
+
assert "Total: 3" in result.output
|
|
115
|
+
|
|
116
|
+
def test_counts_with_prefix_filter(self, tmp_path, monkeypatch):
|
|
117
|
+
db_path = str(tmp_path / "test.db")
|
|
118
|
+
monkeypatch.setenv("CIRCLE_SO_DB", db_path)
|
|
119
|
+
|
|
120
|
+
csv_path = str(tmp_path / "learners.csv")
|
|
121
|
+
with open(csv_path, "w", newline="") as f:
|
|
122
|
+
w = csv.writer(f)
|
|
123
|
+
w.writerow(["first_name", "email", "country", "PLG"])
|
|
124
|
+
w.writerow(["Alice", "a@example.com", "Kenya", "KCNA 001"])
|
|
125
|
+
w.writerow(["Bob", "b@example.com", "Kenya", "CKAD 001"])
|
|
126
|
+
|
|
127
|
+
CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
|
|
128
|
+
result = CliRunner().invoke(main, ["--db", db_path, "report", "counts", "--prefix", "kcna"])
|
|
129
|
+
assert "KCNA 001" in result.output
|
|
130
|
+
assert "CKAD" not in result.output
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestReportMissing:
|
|
134
|
+
def test_missing_none(self, tmp_path, monkeypatch):
|
|
135
|
+
db_path = str(tmp_path / "test.db")
|
|
136
|
+
monkeypatch.setenv("CIRCLE_SO_DB", db_path)
|
|
137
|
+
result = CliRunner().invoke(main, ["--db", db_path, "report", "missing"])
|
|
138
|
+
assert result.exit_code == 0
|
|
139
|
+
assert "No missing" in result.output
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tests for config."""
|
|
2
|
+
import os
|
|
3
|
+
from circle_so.config import Config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestConfig:
|
|
7
|
+
def test_defaults(self, monkeypatch):
|
|
8
|
+
monkeypatch.setenv("CIRCLE_API_TOKEN", "tok123")
|
|
9
|
+
c = Config()
|
|
10
|
+
assert c.api_token == "tok123"
|
|
11
|
+
assert c.rate_limit == 5
|
|
12
|
+
assert c.cache_ttl == 3600
|
|
13
|
+
|
|
14
|
+
def test_overrides(self):
|
|
15
|
+
c = Config(api_token="override", rate_limit=20, db_path="/tmp/test.db")
|
|
16
|
+
assert c.api_token == "override"
|
|
17
|
+
assert c.rate_limit == 20
|
|
18
|
+
assert c.db_path == "/tmp/test.db"
|
|
19
|
+
|
|
20
|
+
def test_env_vars(self, monkeypatch):
|
|
21
|
+
monkeypatch.setenv("CIRCLE_API_TOKEN", "env_tok")
|
|
22
|
+
monkeypatch.setenv("CIRCLE_SO_RATE_LIMIT", "15")
|
|
23
|
+
monkeypatch.setenv("CIRCLE_SO_CACHE_TTL", "7200")
|
|
24
|
+
c = Config()
|
|
25
|
+
assert c.api_token == "env_tok"
|
|
26
|
+
assert c.rate_limit == 15
|
|
27
|
+
assert c.cache_ttl == 7200
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tests for DB connection and migrations."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
from circle_so.db.connection import get_connection, MIGRATIONS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestMigrations:
|
|
7
|
+
def test_creates_tables(self, tmp_path):
|
|
8
|
+
db_path = str(tmp_path / "test.db")
|
|
9
|
+
conn = get_connection(db_path)
|
|
10
|
+
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
|
11
|
+
assert "spaces" in tables
|
|
12
|
+
assert "members" in tables
|
|
13
|
+
assert "moderators" in tables
|
|
14
|
+
assert "moves" in tables
|
|
15
|
+
assert "cache" in tables
|
|
16
|
+
assert "schema_version" in tables
|
|
17
|
+
conn.close()
|
|
18
|
+
|
|
19
|
+
def test_migration_is_idempotent(self, tmp_path):
|
|
20
|
+
db_path = str(tmp_path / "test.db")
|
|
21
|
+
conn1 = get_connection(db_path)
|
|
22
|
+
conn1.close()
|
|
23
|
+
conn2 = get_connection(db_path)
|
|
24
|
+
version = conn2.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
25
|
+
assert version == len(MIGRATIONS) - 1
|
|
26
|
+
conn2.close()
|
|
27
|
+
|
|
28
|
+
def test_schema_version_tracks(self, tmp_path):
|
|
29
|
+
db_path = str(tmp_path / "test.db")
|
|
30
|
+
conn = get_connection(db_path)
|
|
31
|
+
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
32
|
+
assert version >= 0
|
|
33
|
+
conn.close()
|