poc is not product
So after months of fighting with gf not going to template files, I finally decided to put in some effort to make it work.
This was the dumbest keybind in my config, that I copied from someone else without understanding it.
What I am trying to do #
I have jinja templates in a directory called templates. I want to bind gf to
open a template file, but it is trying to open a new file ./base.html
{% extends "base.html" %}
{% if request.state.user %}
{% block title %}Fokais - {{ request.state.user.full_name }} {% endblock %}
{% else %}
{% block title %}Fokais {% endblock %}
{% endif %}
{% block content %}
{% if request.state.user %}
<h1 id="title"
class="inline-block mx-auto text-5xl font-black leading-loose
text-transparent bg-clip-text bg-gradient-to-r from-red-600
via-pink-500 to-yellow-400 ring-red-500 text-shadow-xl
text-shadow-zinc-950 ring-5">
{{ request.state.user.full_name }}
</h1>
{% endif %}
{% include "me_partial.html" %}
{% endblock %}
What did not work #
I tried all sorts of changes to my path, but it still didn’t work.
vim.api.nvim_command("set path+=templates/**")
What I found #
after digging into my keymap I found that I had remaped gf to edit years
ago. This works great if the file is in your current directory, and if it’s not
it makes the file. This bind completely breaks vim’s ability to :find files
and was a terrible keybind that I added probably from someone else years ago
and have literally never used this feature. If gf opens an empty file I
always close it and assume that vim failed to :find the file.
-- Allow gf to open non-existent files
set("", "gf", ":edit <cfile><CR>")
Yes, after that fix I still needed to adjust my path #
I ended up with the following in my options.lua.
-- look for jinja templates in the templates directory
vim.opt.path:append("templates/**")
thoughts on unit tests
Authentication from cli tools can be a bit of a bear, and I have to look it up every time. This is my reference guide for future me to remember how to easily do it.
I set up a fastapi server running on port 8000, it uses a basic auth with
waylonwalker as the username and asdf as the password. The server follows
along with what comes out of the docs. I have it setup to take basic auth,
form username and password, or a bearer token for authentication.
curl #
The og of command line url tools.
# basic auth
curl -u 'waylonwalker:asdf' -X POST localhost:8000/token
# basic auth with password prompt
curl -u 'waylonwalker' -X POST localhost:8000/token
# token
curl -H 'Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3YXlsb253YWxrZXIiLCJleHAiOjE3MDI5NTI2MDJ9.GeYNt7DNal6LTiPoavJnqypaMt4vYeriXdq5lqu1ILg' -X POST localhost:8000/token
wget #
My go to if I want the result to go into a file.
# basic auth
wget -q -O - --auth-no-challenge --http-user=waylonwalker --http-password=asdf --post-data '' localhost:8000/token
# token
wget -q -O - --header="Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3YXlsb253YWxrZXIiLCJleHAiOjE3MDI5NTI2MDJ9.GeYNt7DNal6LTiPoavJnqypaMt4vYeriXdq5lqu1ILg" -O - --post-data '' localhost:8000/token
httpx #
An http client written in python, primarilty used with the python api, but has a nice cli.
# install
python3 -m pip install httpx
# basic auth
httpx -m POST --auth waylonwalker asdf http://localhost:8000/token
# basic auth with password prompt
httpx -m POST --auth waylonwalker - http://localhost:8000/token
# token
httpx -m POST --headers="Authorization" "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3YXlsb253YWxrZXIiLCJleHAiOjE3MDI5NTI2MDJ9.GeYNt7DNal6LTiPoavJnqypaMt4vYeriXdq5lqu1ILg" http://localhost:8000/token
httpie #
A modern http client written in python.
# install
python3 -m pip install httpie
# basic auth
http POST localhost:8000/token -a waylonwalker:asdf
# basic auth with password prompt
http POST localhost:8000/token -a waylonwalker
# token
http POST localhost:8000/token -A bearer -a eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3YXlsb253YWxrZXIiLCJleHAiOjE3MDI5NTI2MDJ9.GeYNt7DNal6LTiPoavJnqypaMt4vYeriXdq5lqu1ILg
httpie with plugin #
# install
python3 -m pip install httpie-credential-store
# usage
http POST localhost:8000/token -A creds
httpie prompt #
http-prompt comes from the httpie org, and has an interactive cli interface
into apis. You can even specify a spec file to autocomplete on api methods.
http-prompt localhost:8000 --auth waylonwalker:asdf --spec openapi.json
I am working on fokais.com’s signup page, and I want to hide the form input during an htmx request. I was seeing some issues where I was able to prevent spamming the submit button, but was still able to get one extra hit on it.
It also felt like nothing was happening while sending the email to the user for verification. Now I get the form to disappear and a spinner to show during the request.
html">HTML #
Let’s start off with the form. It uses htmx to submit a post request to the
post_request route. Note that there is a spinner in the post_request with the
htmx-indicator class.
The intent is to hide the spinner until the request is running, and hide all of the form input during the request.
<form
id="signup-form"
hx-swap-oob="outerHTML"
class="m-4 mx-auto mb-6 flex w-80 flex-col rounded-lg b p-4 shadow-xlc shadow-cyan-500/10"
method="POST"
action="{{ url_for('post_signup') }}"
hx-post="{{ url_for('post_signup') }}"
>
<!--markata-attribution-->
<input
class="mx-8 mt-6 mb-4 border border-black bg-zinc-900 p-1 text-center focus:bg-zinc-800"
<!--markata-attribution-->
type="text"
<!--markata-attribution-->
value="{{ full_name }}"
<!--markata-attribution-->
name="full_name"
<!--markata-attribution-->
placeholder="Full Name"
/>
<!--markata-attribution-->
{% if full_name_error %}
<!--markata-attribution-->
<label class="-mt-6 mb-6 mx-8 text-red-500 p-1 text-center">
<!--markata-attribution-->
{{ full_name_error }}
<!--markata-attribution-->
</label>
<!--markata-attribution-->
{% endif %}
<!--markata-attribution-->
<input
class="mx-8 mb-4 border border-black bg-zinc-900 p-1 text-center focus:bg-zinc-800"
<!--markata-attribution-->
type="text"
<!--markata-attribution-->
value="{{ username }}"
<!--markata-attribution-->
name="username"
<!--markata-attribution-->
placeholder="username"
/>
<!--markata-attribution-->
{% if username_error %}
<!--markata-attribution-->
<label class="-mt-6 mb-6 mx-8 text-red-500 p-1 text-center">
<!--markata-attribution-->
{{ username_error }}
<!--markata-attribution-->
</label>
<!--markata-attribution-->
{% endif %}
<!--markata-attribution-->
<input
class="mx-8 mb-4 border border-black bg-zinc-900 p-1 text-center focus:bg-zinc-800"
<!--markata-attribution-->
type="email"
<!--markata-attribution-->
name="email"
<!--markata-attribution-->
value="{{ email }}"
<!--markata-attribution-->
placeholder="email"
/>
<!--markata-attribution-->
{% if email_error %}
<!--markata-attribution-->
<label class="-mt-6 mb-6 mx-8 text-red-500 p-1 text-center">
<!--markata-attribution-->
{{ email_error }}
<!--markata-attribution-->
</label>
<!--markata-attribution-->
{% endif %}
<!--markata-attribution-->
<input
class="mx-auto w-32 mb-4 border border-black bg-purple-900 p-1 text-center focus:bg-zinc-800"
<!--markata-attribution-->
type="submit"
<!--markata-attribution-->
value="sign up"
/>
<!--markata-attribution-->
<div role="status" class="mx-auto htmx-indicator">
<!--markata-attribution-->
<svg
<!--markata-attribution-->
class="mx-auto animate-spin h-5 w-5 text-white"
<!--markata-attribution-->
xmlns="https://www.w3.org/2000/svg"
<!--markata-attribution-->
fill="none"
<!--markata-attribution-->
viewBox="0 0 24 24"
>
<!--markata-attribution-->
<circle
<!--markata-attribution-->
class="opacity-25"
<!--markata-attribution-->
cx="12"
<!--markata-attribution-->
cy="12"
<!--markata-attribution-->
r="10"
<!--markata-attribution-->
stroke="currentColor"
<!--markata-attribution-->
stroke-width="4"
></circle>
<!--markata-attribution-->
<path
<!--markata-attribution-->
class="opacity-75"
<!--markata-attribution-->
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<!--markata-attribution-->
</svg>
<!--markata-attribution-->
<p>Signing up...</p>
<!--markata-attribution-->
</div>
<!--markata-attribution-->
</form>
Yes this is styled using tailwindcss.
https://waylonwalker.com/still-loving-tailwind/
CSS #
Let’s take a look at how we achieve switching between only spinner an only form inputs using css.
.htmx-indicator {
@apply hidden;
opacity: 0;
transition: opacity 500ms ease-in;
}
.htmx-request button,
.htmx-request input[type="submit"],
.htmx-request input,
.htmx-request label {
@apply hidden;
}
.htmx-request .htmx-indicator {
opacity: 1;
@apply block;
}
.htmx-request.htmx-indicator {
opacity: 1;
@apply block;
}
Final Result #
Here is the final result of me signing up for a new account in fokais.
Today I am working on fokais.com, trying to get to a point where I can launch by workig through stripe integrations. This is my first time using stripe, so there has been quite a bit to learn, and I am probably building in more than I need to before launching, but I am learning, and not in a rush to launch.
I am building the fokais backent in python primarilyt with fastapi and sqlmodel on sqlite. My billing integration is going to be all Stripe.
Stripe Subscription Cancellations Docs #
Here is a link to the stripe docs for your refrence, especially if you want to see how to cancel subscriptions in other languages. They include code samples for many popular languages.
User Model #
This is the part of the user model that includes the cancel and reactivate methods. It pretty much follows the stripe guide.
class UserBase(SQLModel, table=False): # type: ignore[call-arg]
username: str = Field(unique=True)
full_name: str
email: str
email_verified: bool = False
disabled: bool = False
signup_date: Optional[datetime] = Field(default_factory=datetime.utcnow)
stripe_customer_id: Optional[str]
def cancel_subscription(self):
for subscription in self.active_subscriptions:
stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=True,
)
self.refresh()
def reactivate_subscription(self):
for subscription in self.active_subscriptions:
stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=False,
)
self.refresh()
Cancellations api #
Here is the cancellations api. I created an are you sure form that I can link
to from the accounts page with a normal anchor tag. Note that I am doing a
POST request to do the cancellation from a form. I want this to work for any
user whether there is js or not. This is an operation that will change the
users data, and I want to make sure that it avoids all browser and cdn caching.
As a scrappy startup we are running light on infrastructure and are caching
hard at the CDN to avoid excessive server hits.
Note
I am doing a `POST` request to do the cancellation from a form.
@pricing_router.get("/cancel")
@pricing_router.get("/cancel/")
def get_cancel(
request: Request,
current_user: Annotated[User, Depends(get_current_user_if_logged_in)],
):
return config.templates.TemplateResponse(
"cancel.html",
{
"request": request,
"prices": products.prices,
"products": products.products,
"current_user": current_user,
},
)
@pricing_router.post("/cancel")
@pricing_router.post("/cancel/")
def post_cancel(
request: Request,
current_user: Annotated[User, Depends(get_current_user_if_logged_in)],
):
current_user.cancel_subscription()
return HTMLResponse('<p id="cancel" hx-swap-oob="outerHTML">Your Subscription has been Cancelled</p>')
Reactivations #
Reactivating accounts looks just about the same as cancelling, only flippng True to False.
@pricing_router.get("/reactivate")
@pricing_router.get("/reactivate/")
def get_reactivate(
request: Request,
current_user: Annotated[User, Depends(get_current_user_if_logged_in)],
):
return config.templates.TemplateResponse(
"reactivate.html",
{
"request": request,
"prices": products.prices,
"products": products.products,
"current_user": current_user,
},
)
@pricing_router.post("/reactivate")
@pricing_router.post("/reactivate/")
def post_reactivate(
request: Request,
current_user: Annotated[User, Depends(get_current_user_if_logged_in)],
):
current_user.reactivate_subscription()
return HTMLResponse('<p id="reactivate" hx-swap-oob="outerHTML">Your Subscription has been reactivated</p>')
Full User Model #
This is the full user model, completely subject to change in the future, but it includes the cancel and reactivate methods.
class UserBase(SQLModel, table=False): # type: ignore[call-arg]
username: str = Field(unique=True)
full_name: str
email: str
email_verified: bool = False
disabled: bool = False
signup_date: Optional[datetime] = Field(default_factory=datetime.utcnow)
stripe_customer_id: Optional[str]
@property
def session(self):
return next(get_session())
@classmethod
def get_by_id(cls, id):
return next(get_session()).get(cls, id)
def refresh(self):
cache.set(f"active_subscriptions_{self.id}", None, 3600)
cache.set(f"active_products_{self.id}", None, 3600)
def get_checkout_sessions(self):
return [
stripe.checkout.Session.retrieve(s.stripe_checkout_session_id)
for s in self.session.exec(select(CheckoutSession).where(CheckoutSession.user_id == self.id)).all()
if s.stripe_checkout_session_id is not None
]
def get_active_subscriptions(self):
subscriptions = [
s.subscription
for s in [
stripe.checkout.Session.retrieve(s.stripe_checkout_session_id)
for s in self.session.exec(select(CheckoutSession).where(CheckoutSession.user_id == self.id)).all()
if s.stripe_checkout_session_id is not None
]
if s.status == "complete"
]
active_subscriptions = [stripe.Subscription.retrieve(subscription) for subscription in subscriptions]
return active_subscriptions
def has_active_subscription(self):
return len(self.active_subscriptions) > 0
@property
def active_subscriptions(self):
active_subscriptions = cache.get(f"active_subscriptions_{self.id}")
if active_subscriptions is not None:
return active_subscriptions
active_subscriptions = self.get_active_subscriptions()
cache.set(f"active_subscriptions_{self.id}", active_subscriptions, 3600)
return active_subscriptions
@property
def active_plans(self):
subscriptions = self.active_subscriptions
plans = [subscription.plan for subscription in subscriptions]
return plans
@property
def subscription_to_plan(self):
subscriptions = self.active_subscriptions
plans = {subscription.id: subscription.plan.id for subscription in subscriptions}
return plans
@property
def plan_to_subscription(self):
plans = {v: k for k, v in self.subscription_to_plan.items()}
return plans
def get_active_products(self):
plans = self.active_plans
products = [stripe.Product.retrieve(plan.product) for plan in plans]
return products
@property
def plan_to_product(self):
plans = self.active_plans
products = {plan.id: stripe.Product.retrieve(plan.product).id for plan in plans}
return products
@property
def prodct_to_plan(self):
plans = self.active_plans
products = {stripe.Product.retrieve(plan.product).id: plan.id for plan in plans}
return products
@property
def active_products(self):
products = cache.get(f"active_products_{self.id}")
if products is not None:
return products
products = self.get_active_products()
cache.set(f"active_products_{self.id}", products, 3600)
return products
@property
def best_active_subscription(self):
subscriptions = self.active_subscriptions
return subscriptions[0]
@property
def best_active_product(self):
products = self.active_products
products.sort(key=lambda p: p.metadata.get('level', 0))
return products[0]
@property
def best_active_subscription(self):
subscription_id = self.plan_to_subscription[self.prodct_to_plan[self.best_active_product.id]]
return stripe.Subscription.retrieve(subscription_id)
@property
def config(self):
product = self.best_active_product
return product.metadata
def subscription_status(self):
subscriptions = self.active_subscriptions()
def cancel_subscription(self):
for subscription in self.active_subscriptions:
stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=True,
)
self.refresh()
def reactivate_subscription(self):
for subscription in self.active_subscriptions:
stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=False,
)
self.refresh()